Compare commits

...
Author SHA1 Message Date
Félix Malfait 46b0c46beb fix(workflow): include relation target field metadata in find-records action
Workflow Search Records action built its fields list from
flatObjectMetadata.fieldIds — only the source object's own fields. The
shared dispatcher then couldn't resolve the relation target field by id
and silently dropped the filter, so a configured one-hop traversal
(e.g. People where Company → Name Contains "Airbnb") wasn't applied.

Collect relationTargetFieldMetadataId from the input record filters
and add them to the field id list before resolving against
flatFieldMetadataMaps.
2026-05-18 11:12:59 +02:00
Félix Malfait 52726ce943 fix(filters): plumb flattenedFieldMetadataItems through remaining computeContextStoreFilters callers 2026-05-15 15:03:56 +02:00
Félix Malfait 312bb37c40 fix(filters): include relation target field metadata in frontend gql filter compute
Every call site of computeRecordGqlOperationFilter was passing only the
source object's fields (objectMetadataItem.fields). The dispatcher needs
the relation target field by id to nest the inner filter under the
source field's GraphQL key — without it, the target lookup returns
undefined and the filter is silently dropped, producing { and: [] }.

The user-visible symptoms: relation-traversal chips show in the UI but
the GraphQL request carries an empty filter, and typing in the value
input doesn't trigger a refetch (variables unchanged because the filter
is being dropped at compute time).

Introduce augmentFieldsWithRelationTargets which extends the base field
list with the target fields referenced by the record filters, resolved
from flattenedFieldMetadataItemsSelector (workspace-wide flat field
list). Wire it through every computeRecordGqlOperationFilter caller in
the frontend (record table table/aggregate/empty/SSE paths, record
index group + total count, graph widget, calendar date range, context
store filters via three callers, parent view query variables).
2026-05-15 14:59:00 +02:00
Félix MalfaitandGitHub 4f707bb9f1 refactor(twenty-shared): split turnRecordFilterIntoGqlOperationFilter into dispatcher + direct builder (#20600)
Stacked on top of #20533 — merge that one first.

## Summary

Splits `turnRecordFilterIntoRecordGqlOperationFilter` into a thin
dispatcher and a private `buildDirectFieldGqlOperationFilter`. Same
observable behaviour, but:

- The two ordering-constraint comments that #20533 introduced go away
(\"must run before the emptiness shortcut\", \"drop rather than fall
through to legacy relation-by-record\"). They were both flagging a real
code smell: dispatch and direct-filter logic were interleaved in the
same function body and depended on a flag inside the input.
- Removes the self-recursion + \"inject target into fieldMetadataItems\"
hack.
- Removes three duplicated `fieldMetadataItems.find(...)` lookups that
only existed to compose error-message labels — the resolved
`fieldMetadataItem` was already in scope.
- Renames `correspondingFieldMetadataItem` → `fieldMetadataItem` inside
the extracted function (the variable IS the field; the longer prefix
predated the split).

## Why

Discussed in #20533: the conditional ordering between the
relation-traversal branch and the per-type switch was easy to get wrong,
and the legacy `case 'RELATION':` foot-gun (parsing the filter value as
a UUID list when it's actually a text value) was guarded by a defensive
`return` rather than by structure.

After this PR the dispatcher picks exactly one branch up front and
`buildDirectFieldGqlOperationFilter` only ever runs against the field
the filter operates on — no \"is this the source field or the target
field?\" ambiguity inside the switch.

## Test plan
- [x] `npx nx build twenty-shared` succeeds (no type regressions).
- [x] `jest packages/twenty-shared` — 1212 tests pass.
- [x] `jest view-query-params.service.spec.ts` — 7 tests pass, including
the relation-traversal round-trip case.
- [ ] CI green.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 14:04:46 +02:00
Félix Malfait e0e36a8f00 chore: prettier format 2026-05-15 13:46:34 +02:00
Félix Malfait 4a78b092a2 refactor(advanced-filter): drop dead composite fallback + extract focus-push helper
- useApplyAdvancedFilterSourceField is only reached on leaf source fields
  (callers branch to the composite sub-menu hook or relation target hook
  before calling it). Removed the now-unreachable composite "any
  subfield" fallback and its three imports.
- The three callers that push the focus stack for a leaf RELATION/SELECT
  field (AdvancedFilterFieldSelectMenu, AdvancedFilterRelationTargetFieldSelectMenu,
  SettingsRolePermissions...FieldSelectFieldMenu) shared the same 8-line
  block — extracted into usePushFocusForLeafFieldValuePicker.
2026-05-15 13:42:20 +02:00
Félix MalfaitandGitHub 0a18423149 Merge branch 'main' into feat/graphql-relation-traversal-filters-frontend 2026-05-15 13:24:21 +02:00
Félix Malfait e3638925a2 fix(filters): drop relation-traversal view filter when target field cannot be resolved
mapViewFiltersToFilters used to fall back to the source field's filterType
and label when the relation target couldn't be found in
fieldMetadataItems. The resulting RecordFilter still carried the
unresolved relationTargetFieldMetadataId, and the dispatcher would later
drop the GraphQL filter anyway — leaving a chip in the UI that didn't
match the query.

Treat an unresolvable target the same as an unresolvable source: drop
the filter at mapping time. Race conditions during view change (the same
race the source-field handling documents) are the expected reason for a
transient miss and don't warrant a thrown error.
2026-05-15 13:14:49 +02:00
Félix Malfait 88d0476da0 fix(advanced-filter): reset relation-target-selecting state on dropdown dismiss + typecheck
- useResetFilterDropdown now clears
  objectFilterDropdownIsSelectingRelationTargetField, mirroring the
  composite-field reset. Without this, dismissing the dropdown mid-pick
  (Escape, click-away) left the flag true and the next open jumped
  straight to the relation target sub-menu.
- useApplyAdvancedFilterSourceField: tighten the defaultSubFieldName
  type. getDefaultSubFieldNameForCompositeFilterableFieldType can
  return undefined when the composite type isn't filterable by any
  subfield path, so coalesce to null to satisfy the
  CompositeFieldSubFieldName | null type expected by RecordFilter.
2026-05-15 13:10:20 +02:00
Félix Malfait 203f72116a fix(advanced-filter): exclude many-to-one relations from relation target picker
The backend supports a single-hop relation traversal. If the user picked
a many-to-one relation field as the target, the dispatcher would
substitute the target id into fieldMetadataId and recurse — the inner
call has relationTargetFieldMetadataId set to null, so it falls through
to the per-type RELATION case which expects a UUID list, not a primitive
value. The UI offered a traversal that the backend silently demoted to
filter-by-id.

Filter many-to-one fields out of the target list so only one-hop
traversals can be composed.
2026-05-15 13:05:21 +02:00
Félix Malfait a23ab663bc chore: prettier format 2026-05-15 12:59:40 +02:00
Félix Malfait 2bd03cab91 fix(view-widget-upsert): resolve relationTargetFieldMetadataUniversalIdentifier on filter create
The CREATE path hardcoded relationTargetFieldMetadataUniversalIdentifier
to null while passing the FK id through. The migration system's
resolveUniversalRelationIdentifiersToIds then renormalises by treating
the null universal identifier as authoritative and nullifying the FK id,
silently dropping the relation traversal on save.

Mirror the UPDATE path: include relationTargetFieldMetadataId in the
foreignKeyValues passed to resolveEntityRelationUniversalIdentifiers and
read the resolved universal identifier from its result.
2026-05-15 12:55:04 +02:00
Félix Malfait 50bd2a78a7 fix(advanced-filter): defer record filter upsert until sub-menu choice is final
When the user picked a relation or composite source field and then backed
out of the sub-menu, the immediate upsertRecordFilter call left an
orphaned partial filter in the chip list (a relation-by-id filter with no
UUIDs, or a composite filter with the "any subfield" fallback). The chip
appeared functional but couldn't actually filter anything.

The source-field handler now only stages the source choice in dropdown
state for the sub-menu paths and lets the sub-menu's hook do the upsert
on the final pick. The leaf path keeps the immediate upsert.
2026-05-15 12:52:17 +02:00
Félix Malfait 54894044b2 refactor(advanced-filter): split useSelectFieldUsedInAdvancedFilterDropdown into 3 specialized hooks
- useApplyAdvancedFilterSourceField for the source field choice
  (handles composite "any subfield" fallback for the initial pick)
- useApplyAdvancedFilterCompositeSubField for the composite subfield
  selection (no relation-target branching)
- useApplyAdvancedFilterRelationTargetField for the relation traversal
  target selection (derives filterType + label from target)

Each menu component calls its own dedicated hook — no shared
isRelationTraversal branching, no shared subFieldName fallback path.
The old useSelectFieldUsedInAdvancedFilterDropdown hook is removed.
2026-05-15 12:47:29 +02:00
Félix Malfait 7158fab9fa fix(filters): include relation target fields in fields list, fix deleted-target crash
- view-query-params and common-group-by include relation target field metadata
  in the fields list passed to the dispatcher so the traversal branch can
  resolve the target field by id
- ObjectFilterDropdownInnerSelectOperandDropdown swaps the throwing lookup
  for the non-throwing getFieldMetadataItemById so a deleted target field
  renders nothing instead of crashing
- lint: rename setIsSelectingRelationTargetField and isSelectingCompositeField
  to match their underlying component state names
2026-05-15 12:42:04 +02:00
Félix Malfait 858ae62b67 refactor(filters): split advanced-filter sub-menu, derive relation target on demand
- RecordFilter only carries relationTargetFieldMetadataId; derive label/type at render
- Split AdvancedFilterSubFieldSelectMenu into Composite + RelationTargetField components
- Dedicated objectFilterDropdownIsSelectingRelationTargetField signal (no RELATION sentinel)
- mapViewFiltersToFilters takes a single fieldMetadataItems list
- Server view-query-params and common-group-by stop embedding resolved relation target
- Lift focus-stack push out of useSelectFieldUsedInAdvancedFilterDropdown
2026-05-15 11:09:10 +02:00
Félix Malfait 41239c9f0e refactor(twenty-front): move sub-menu type / constant out of state, extract relation sub-menu
Two review nits:

1. `RELATION_SUB_MENU_FIELD_TYPE` and `ObjectFilterDropdownSubMenuFieldType`
   lived inside the state file. They aren't state — moved to
   `object-filter-dropdown/constants/` and `record-filter/types/`
   respectively, alongside the rest of the constants and types.

2. The relation sub-menu branch in `AdvancedFilterSubFieldSelectMenu`
   computed `targetObjectMetadataId` with an empty-string sentinel so
   the always-running `useFilterableFieldMetadataItems` hook could
   accept it. Fragile (the `''` sentinel is unnamed, untyped, and
   relies on the selector silently returning `[]`). Extracted the
   relation branch into its own `AdvancedFilterRelationSubMenu`
   component that only mounts when actually in relation mode — the
   hook now always receives a real object id, no sentinel.

Also: turned `isManyToOneRelationField` into a generic type guard so
callers can read `field.relation.targetObjectMetadata.id` after the
check without a non-null assertion.
2026-05-15 10:32:20 +02:00
Félix Malfait 49633515d2 fix(twenty-front): treat composite and relation sub-menus symmetrically
Per review feedback: the relation sub-menu case wasn't actually different
from the composite sub-menu case — they're both "another dropdown that
manages its own focus, source-field push would shadow it." The previous
implementation skipped the push only for relation traversal because that
was the only case where the smell was visible (composite source fields
aren't RELATION/SELECT so the original conditional push didn't fire for
them). Generalising:

- AdvancedFilterFieldSelectMenu: skipFocusPush = subMenuType !== null
- AdvancedFilterSubFieldSelectMenu: skipFocusPush = true (always —
  the next dropdown is for a sub-field or target field, never the source).
2026-05-15 10:19:28 +02:00
Félix Malfait 90a1381c86 fix(twenty-front): skip focus-stack push when relation traversal opens sub-menu
useSelectFieldUsedInAdvancedFilterDropdown was pushing fieldMetadataItem.id
onto the focus stack whenever the source field was RELATION or SELECT —
originally for the entity / option picker that opens next. With relation
traversal, a RELATION source now opens the sub-menu instead, and the
sub-menu's SelectableList uses advancedFilterFieldSelectDropdownId as its
focusId. The mismatched push at the top of the stack shadowed the
sub-menu's hotkey scope and disabled keyboard navigation.

Add a skipFocusPush opt-out, set by:
- AdvancedFilterFieldSelectMenu when the next step is the relation
  sub-menu (case 1, entering)
- AdvancedFilterSubFieldSelectMenu when committing with a resolved
  relationTargetFieldMetadataItem (case 2, leaving — the target's value
  picker will set up its own focus when opened)

Role-permission callers keep the original push behaviour (they go
directly to a value picker, no sub-menu).
2026-05-15 07:31:39 +02:00
Félix Malfait d947fed809 fix(twenty-server): align relationTargetFieldMetadata ON DELETE to CASCADE
The other foreign keys on viewFilter referencing fieldMetadata / view /
viewFilterGroup all use ON DELETE CASCADE. The new column was using
SET NULL, which required a runtime "drop filter when target gone" path
and left a half-broken row in the table. Align to CASCADE so a deleted
field cleans up its filters atomically, matching the pre-existing
convention on this entity.
2026-05-15 07:11:38 +02:00
Félix Malfait d39a2d00c1 fix(twenty-server): apply relationTargetFieldMetadataId on widget filter update
In ViewWidgetUpsertService.computeViewFilterOperations the hasChanged
branch correctly detected a change to relationTargetFieldMetadataId but
the entry pushed to filtersToUpdate only spread existingFilter and never
applied the new value (or recomputed its universal identifier). The user's
change was silently ignored. Update path now mirrors the create branch:
includes relationTargetFieldMetadataId and resolves the matching
universal identifier alongside the source field and view-filter-group.
2026-05-14 22:49:10 +02:00
Félix Malfait 68d513662c test(twenty-server): integration test for relation-traversal view filter round-trip
Asserts that the metadata GraphQL Create + Update mutations correctly
persist and surface `relationTargetFieldMetadataId` on view filters:

- create with relationTargetFieldMetadataId set → returns it on the DTO
- create without it → defaults to null
- update relationTargetFieldMetadataId → new value is reflected on the DTO
- update an unrelated property (value) → relationTargetFieldMetadataId
  is preserved (regression test for the universal-identifier-recompute
  fix in fromUpdateViewFilterInputToFlatViewFilterToUpdateOrThrow)

Uses the standard Person.company MANY_TO_ONE relation pointing at
Company.name / Company.domainName so the test runs against seed data.
2026-05-14 22:44:23 +02:00
Félix Malfait 24884e9a27 fix: handle relation traversal in update flow, group-by runner, value reopen
- from-update-view-filter-input-to-flat-view-filter-to-update-or-throw:
  recompute relationTargetFieldMetadataUniversalIdentifier when the
  editable property changes. Without this, workspace migration sync
  diffs would miss or mishandle relation-target changes on existing
  filters (Create already handled this; Update did not).

- common-group-by-query-runner.service: when converting saved view
  filters to record filters for dashboard widget aggregates, look up
  the relation target field and embed it on the RecordFilter (same
  pattern as view-query-params.service). Without this, saved relation
  traversal filters are silently dropped from group-by queries.

- AdvancedFilterValueInput: when reopening a filter chip's value
  dropdown, restore relationTargetFieldMetadataIdUsedInDropdown from
  the filter so the operand picker reflects the target's type (not
  the relation field's RELATION type).

- useGetRecordFilterDisplayValue: read SELECT / MULTI_SELECT options
  from the target field (when relation traversal is in play) so chip
  labels show the target field's option labels, not the source's.
2026-05-14 22:33:05 +02:00
Félix Malfait 3040e75e97 refactor: embed resolved relation target on RecordFilter
Replace the cross-object field lookup that bled into ~9 callers with a
self-contained filter shape: RecordFilter now carries an optional
relationTargetField: { id, name, type, label } resolved at construction
time. The shared turnRecordFilterIntoRecordGqlOperationFilter reads the
target directly off the filter and injects it into the recursive lookup
array, so downstream callers no longer need to pass the flattened
cross-object metadata.

Effect on the diff:
- RecordFilter shape gains relationTargetField (shared + frontend types).
- turnRecordFilterIntoRecordGqlOperationFilter uses recordFilter.
  relationTargetField directly; no caller-supplied lookup needed.
- Filter construction sites embed the resolved target:
    mapViewFiltersToFilters (frontend),
    useSelectFieldUsedInAdvancedFilterDropdown (frontend),
    view-query-params.service.ts (server).
- Revert 9 consumer callers to objectMetadataItem.fields:
    useFindManyRecordIndexTableParams, useGraphWidgetQueryCommon,
    useRecordIndexGroupCommonQueryVariables,
    useRecordIndexGroupsAggregatesGroupBy,
    RecordTableEmptyHasNewRecordEffect,
    useAggregateRecordsForRecordTableColumnFooter,
    RecordTableVirtualizedSSESubscribeEffect,
    useGetRecordIndexTotalCount, useRecordCalendarQueryDateRangeFilter.
- computeContextStoreFilters drops its optional flattened-fields param;
  reverts 5 callers (useUpdateMultipleRecordsActions,
  useRecordIndexLazyFetchRecords, useFindManyRecordsSelectedInContextStore,
  RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect,
  buildHeadlessCommandContextApi).
- getQueryVariablesFromFiltersAndSorts reverts to single-object fields.
- mapRecordFilterToViewFilter derives the persisted id from
  recordFilter.relationTargetField?.id.

Net −79 LOC and the filter is now self-contained.
2026-05-14 22:27:27 +02:00
Félix Malfait 4a37f654f8 fix: prevent silent fallback when relation-traversal filter target missing
- turnRecordFilterIntoRecordGqlOperationFilter now drops the filter instead
  of falling through to the legacy relation-by-record path when
  relationTargetFieldMetadataId is set but the target field isn't in the
  provided fieldMetadataItems. The legacy path parses the value as a UUID
  list, which would silently mishandle target-field values like "Acme" and
  could broaden destructive operations to every record.
- Update remaining callers of computeRecordGqlOperationFilter /
  computeContextStoreFilters to pass the flattened cross-object field list
  so the target field is resolvable: useGetRecordIndexTotalCount,
  getQueryVariablesFromFiltersAndSorts, useUpdateMultipleRecordsActions,
  useRecordIndexLazyFetchRecords, useFindManyRecordsSelectedInContextStore,
  RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect,
  buildHeadlessCommandContextApi, useRecordCalendarQueryDateRangeFilter.
- computeContextStoreFilters accepts an optional flattenedFieldMetadataItems
  param that falls back to objectMetadataItem.fields.
2026-05-14 21:54:59 +02:00
Félix Malfait 2dc23464e7 fix: propagate relationTargetFieldMetadataId server-side, fix prettier
- ViewQueryParamsService now carries relationTargetFieldMetadataId onto
  RecordFilter and includes the target field in the fields array passed to
  computeRecordGqlOperationFilter so server-side flows (dashboard widgets,
  workflows, etc.) execute the relation traversal branch instead of falling
  back to legacy relation-by-record matching. Add an integration-style spec
  asserting the nested filter shape.
- Prettier fix in filterSortableFieldMetadataItems.
2026-05-14 21:41:45 +02:00
Félix Malfait 06f8c8b5a7 refactor: address review feedback and simplify
Address PR review comments:
- Move isManyToOneRelationField to @/object-metadata/utils (global helper).
  Refactor prefillRecord, sanitizeRecordInput, filterSortableFieldMetadataItems
  and spreadsheetImportHasNestedFields to reuse it.
- Drop narrative/redundant comments on RecordFilter, ViewFilter shape,
  RELATION_SUB_MENU_FIELD_TYPE sentinel and relationTargetFieldMetadataIdUsed
  state — names are self-describing.

Simplifications:
- Unify the two select-filter handlers in AdvancedFilterSubFieldSelectMenu
  behind a single keyword-args entry point.
- Collapse isRelationDrillDown/isCompositeDrillDown flags in
  AdvancedFilterFieldSelectMenu into a single computed sub-menu type.
- Drop the redundant `'relationTargetFieldMetadataId' in viewFilter` check
  in mapViewFiltersToFilters now that ViewFilter shape carries the field.
- Drop the over-engineered useMemo around effectiveFieldMetadataItem in
  ObjectFilterDropdownInnerSelectOperandDropdown — the underlying lookup
  is a cheap dictionary read.
- Reuse the same relationTargetFieldMetadataId for both the filter object
  and the dropdown state setter in useSelectFieldUsedInAdvancedFilterDropdown.
2026-05-14 21:28:13 +02:00
Félix Malfait fd1b51ac03 fix(twenty-server): relocate relation-target-field migration to 2-6 directory
Main bumped current version to 2.6.0; the upgrade-mutation guard requires
new commands to live in the current version's directory. Move the file,
rename the timestamp prefix, and bump the @RegisteredInstanceCommand version.
2026-05-14 20:43:04 +02:00
Félix MalfaitandGitHub b805b962f1 Merge branch 'main' into feat/graphql-relation-traversal-filters-frontend 2026-05-14 20:37:53 +02:00
Félix Malfait 96a6a08a51 fix(twenty-server): bump viewFilter in children-first sort snapshot
Adding the relationTargetFieldMetadata many-to-one relation gives viewFilter
one more parent-pointing edge, which shifts it ahead of commandMenuItem/
fieldPermission/viewField in the children-first ordering.
2026-05-14 17:11:07 +02:00
Félix Malfait 0153ea3887 fix: align generated graphql DocumentNodes, view-filter test, and properties snapshot
- Add relationTargetFieldMetadataId to 17 ViewFilter DocumentNode constants
  (codegen was missing these AST entries even though the TS shapes had the
  field).
- Update useApplyCurrentViewFiltersToCurrentRecordFilters expectation to
  include relationTargetFieldMetadataId: null in the produced RecordFilter.
- Add relationTargetFieldMetadataUniversalIdentifier to viewFilter
  propertiesToCompare snapshot.
2026-05-14 16:53:15 +02:00
Félix Malfait f3211f1d52 fix(twenty-front): remove relationTargetFieldMetadataId from ViewSort generated shapes
Earlier patch used an overly broad sed that also matched ViewSort inline
shapes (since ViewSort also has subFieldName, viewId). ViewSort doesn't
have this column - only ViewFilter does.
2026-05-14 16:26:08 +02:00
Félix MalfaitandGitHub e97f466fe6 Merge branch 'main' into feat/graphql-relation-traversal-filters-frontend 2026-05-14 16:11:47 +02:00
Félix Malfait 6076a79bda fix: regen generated graphql + SDK metadata client for relationTargetFieldMetadataId
The ViewFilter DTO / inputs gained `relationTargetFieldMetadataId` in
this PR but I had forgotten to re-run the schema generators that mirror
the GraphQL surface into typed clients:

- twenty-front/src/generated-metadata/graphql.ts — add the column to
  CreateViewFilterInput, UpdateViewFilterInputUpdates,
  UpsertViewWidgetViewFilterInput, the ViewFilter type, and the 22+
  inline `Array<{ __typename: 'ViewFilter', ... }>` shapes used by
  query/mutation result types and fragments.
- twenty-client-sdk/src/metadata/generated/{schema.graphql,schema.ts,
  types.ts} — same field on each of the four shapes (type +
  Create / UpdateUpdates / UpsertViewWidget inputs) plus the
  ViewFilterGenqlSelection helper and the four introspection-data
  entries in types.ts.

Diff matches what the codegen+sdk generators would emit (validated
field-by-field against the diff CI's server-validation step
produced).
2026-05-14 16:06:44 +02:00
Félix Malfait 1f4225fb83 fix(twenty-server): align relation-target-field migration with TypeORM's expected SQL
The server-validation step (`database:migrate:generate`) emits a pending
migration whenever the entity-derived schema differs from what existing
migrations produced. The original migration drifted in three ways that
TypeORM's generator flagged:

- FK constraint name was a human-readable
  `FK_VIEW_FILTER_RELATION_TARGET_FIELD_METADATA_ID` instead of the
  hash that `DefaultNamingStrategy.foreignKeyName` produces (sha1 of
  `<tableName>_<columns>` truncated to 27 chars) →
  `FK_dbe259395cbd9a54c1c17d12b0b`.
- The FK lacked `ON UPDATE NO ACTION`, which TypeORM emits by default.
- Operation order was column → FK → index; TypeORM emits
  column → index → FK.

Also dropped the `IF NOT EXISTS` guards on column / index / constraint
since TypeORM-generated migrations don't use them.
2026-05-14 15:43:49 +02:00
Félix MalfaitandGitHub 36aadfa649 Merge branch 'main' into feat/graphql-relation-traversal-filters-frontend 2026-05-14 15:33:33 +02:00
Félix Malfait 9529764c94 fix(twenty-front): typecheck — read selector via .atom and coerce undefined to null
- store.get on the custom `flattenedFieldMetadataItemsSelector` needs
  `.atom` to match Jotai's Atom<T> shape (callback context can't use
  useAtomStateValue).
- recordFilter.relationTargetFieldMetadataId is string | null | undefined;
  the component state expects string | null, so coerce with ?? null.
2026-05-14 15:23:47 +02:00
Félix Malfait a90a6e3bc6 fix(views): make relation traversal filters survive serialize + reload
Four bugs caught in PR review of the one-hop relation filter feature:

- turnRecordFilterIntoRecordGqlOperationFilter (twenty-shared) looks up
  the target field in the same `fieldMetadataItems` list it received,
  but every frontend caller was passing only the current object's
  fields. The target field never resolved, so the serializer fell
  through to the legacy RELATION case and threw a ZodError trying to
  parse the text value as record IDs. Fix: feed every call site the
  `flattenedFieldMetadataItemsSelector` value (a memoized flat-map of
  every object's fields). `turnAnyFieldFilterIntoRecordGqlFilter`
  iterates and is left on the current-object list.

- mapViewFiltersToFilters derived `type` and `label` from the source
  RELATION field on load, so a saved `Company → Name` filter came back
  as type=RELATION / label=Company and the advanced-filter UI reopened
  it with record-picker semantics. Add an optional
  `allFieldMetadataItems` param, resolve the target field through it,
  and use the target's filterType / build a `source → target` label.
  Callers updated to pass the flattened list.

- view-filter.entity.ts comment promised "graceful fallback to
  relation-by-record semantics" on ON DELETE SET NULL, but nothing
  rewrites the persisted operand/value to fit the new shape — load
  path was the implicit dead-letter office. Rewrite the comment to
  describe the actual contract (row survives, caller drops the
  filter); no behavior change.

- relationTargetFieldMetadataIdUsedInDropdownComponentState had a
  writer in the advanced-filter field-select hook and a reader in the
  simple operand dropdown (chip-edit), but the contexts never met:
  chip-edit init didn't restore the state, so editing a saved
  traversal filter via its chip showed the source field's operand
  list. Wire setEditableFilterChipDropdownStates to also restore
  relationTargetFieldMetadataId from the loaded recordFilter.

Also unfreezes the viewMapFunctions test that was already failing on
the branch (the persisted relation-target field wasn't covered in the
expected shape).
2026-05-14 15:23:41 +02:00
Félix MalfaitandClaude Opus 4.7 8461ddbadd feat(views): persist one-hop relation filters via relationTargetFieldMetadataId
Introduces a dedicated, stable identifier for the relation-traversal filter
target on `ViewFilterEntity` so saved views survive renames of the field
they reference, and removes the shortcut that previously cast a target
field's NAME into the closed `CompositeFieldSubFieldName` slot.

Schema
 - New column `core.viewFilter.relationTargetFieldMetadataId` (uuid,
   nullable, FK to `core.fieldMetadata.id` with ON DELETE SET NULL so
   filters degrade gracefully if their target field is deleted, rather
   than vanishing).
 - Partial index on the column where not-null.
 - Instance-fast migration (2.5.0, ts 1798000005000) adds the column,
   the FK, and the index; registered in `INSTANCE_COMMANDS`.
 - Field metadata configuration wired through:
   `FLAT_VIEW_FILTER_EDITABLE_PROPERTIES`,
   `all-entity-properties-configuration-by-metadata-name`,
   `all-many-to-one-metadata-foreign-key`,
   `all-many-to-one-metadata-relations` (universal foreign key
   `relationTargetFieldMetadataUniversalIdentifier`).

API
 - `ViewFilterDTO`, `CreateViewFilterInput`, `UpdateViewFilterInputUpdates`
   and `UpsertViewWidgetViewFilterInput` all carry the new optional UUID.
 - View → flat converters (`fromViewFilterEntityToFlatViewFilter`,
   `fromCreateViewFilterInputToFlatViewFilterToCreate`,
   `fromViewFilterManifestToUniversalFlatViewFilter`,
   `createStandardViewFilterFlatMetadata`,
   widget upsert, create-action handler) thread the new column
   through both the foreign-key and universal-identifier sides.

Shared
 - `RecordFilter.relationTargetFieldMetadataId` added on both the
   frontend and twenty-shared types.
 - `turnRecordFilterIntoRecordGqlOperationFilter` now branches on
   `relationTargetFieldMetadataId` BEFORE the emptiness shortcut and
   synthesizes the inner filter against the target field, wrapping it
   under the relation field's name (`{ company: { name: { ... } } }`).
   Falls through to the existing relation-by-record path if the target
   field isn't present in the provided `fieldMetadataItems`.

Frontend
 - New dedicated state
   `relationTargetFieldMetadataIdUsedInDropdownComponentState` (the
   existing `subFieldNameUsedInDropdownComponentState` stays narrowly
   typed for composite sub-fields).
 - `useSelectFieldUsedInAdvancedFilterDropdown` accepts an optional
   `relationTargetFieldMetadataItem`. When present it stores the target
   field's id on `RecordFilter.relationTargetFieldMetadataId` and uses
   the TARGET field's type (so operand picker / value input behave as if
   filtering it directly) and a "Relation → Target" label.
 - `ObjectFilterDropdownInnerSelectOperandDropdown` resolves the
   effective field via `useGetFieldMetadataItemByIdOrThrow` when the
   relation-target state is set, so operand options match the target's
   filterable type.
 - `AdvancedFilterFieldSelectMenu` routes MANY_TO_ONE clicks into the
   same sub-menu flow as composite fields, with a `'RELATION'` sentinel
   on `objectFilterDropdownSubMenuFieldType`.
 - `AdvancedFilterSubFieldSelectMenu` renders the target object's
   filterable fields via `useFilterableFieldMetadataItems`; the ghost
   `-1` selectable id that the composite branch used for the "Any X
   field" item is no longer included in the relation list (fixes the
   keyboard-navigation issue cubic-dev-ai flagged).
 - `ObjectFilterDropdownFilterSelectMenuItem` shows the sub-menu
   chevron for MANY_TO_ONE relations via a new
   `isManyToOneRelationField` util.
 - `mapRecordFilterToViewFilter` / `mapViewFiltersToFilters`,
   `areViewFiltersEqual`, the save mutation inputs, and the GraphQL
   fragment all thread the new field through the round-trip so saved
   views actually persist relation traversal.
 - Role-permissions sub-field menu narrows the `'RELATION'` sentinel
   back out (it never deals with relations).

Out of scope
 - Normal filter dropdown (`ViewBarFilterDropdownFieldSelectMenu`) —
   composite drill-down doesn't exist there today, so adding relation
   drill-down would mean introducing the entire sub-menu pattern on the
   normal surface. Tracking as follow-up; advanced filter is the only
   surface that currently builds nested filter shapes.
 - REST DSL, ONE_TO_MANY reverse traversal, aggregates — unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:23:34 +02:00
89 changed files with 1824 additions and 415 deletions
@@ -771,6 +771,7 @@ type ViewFilter {
viewFilterGroupId: UUID
positionInViewFilterGroup: Float
subFieldName: String
relationTargetFieldMetadataId: UUID
viewId: UUID!
workspaceId: UUID!
createdAt: DateTime!
@@ -3416,6 +3417,7 @@ input CreateViewFilterInput {
viewFilterGroupId: UUID
positionInViewFilterGroup: Float
subFieldName: String
relationTargetFieldMetadataId: UUID
viewId: UUID!
}
@@ -3434,6 +3436,7 @@ input UpdateViewFilterInputUpdates {
viewFilterGroupId: UUID
positionInViewFilterGroup: Float
subFieldName: String
relationTargetFieldMetadataId: UUID
}
input DeleteViewFilterInput {
@@ -3522,6 +3525,7 @@ input UpsertViewWidgetViewFilterInput {
viewFilterGroupId: UUID
positionInViewFilterGroup: Float
subFieldName: String
relationTargetFieldMetadataId: UUID
}
input UpsertViewWidgetViewFilterGroupInput {
@@ -523,6 +523,7 @@ export interface ViewFilter {
viewFilterGroupId?: Scalars['UUID']
positionInViewFilterGroup?: Scalars['Float']
subFieldName?: Scalars['String']
relationTargetFieldMetadataId?: Scalars['UUID']
viewId: Scalars['UUID']
workspaceId: Scalars['UUID']
createdAt: Scalars['DateTime']
@@ -3451,6 +3452,7 @@ export interface ViewFilterGenqlSelection{
viewFilterGroupId?: boolean | number
positionInViewFilterGroup?: boolean | number
subFieldName?: boolean | number
relationTargetFieldMetadataId?: boolean | number
viewId?: boolean | number
workspaceId?: boolean | number
createdAt?: boolean | number
@@ -5973,7 +5975,7 @@ export interface CreateViewFilterGroupInput {id?: (Scalars['UUID'] | null),paren
export interface UpdateViewFilterGroupInput {id?: (Scalars['UUID'] | null),parentViewFilterGroupId?: (Scalars['UUID'] | null),logicalOperator?: (ViewFilterGroupLogicalOperator | null),positionInViewFilterGroup?: (Scalars['Float'] | null),viewId?: (Scalars['UUID'] | null)}
export interface CreateViewFilterInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],operand?: (ViewFilterOperand | null),value: Scalars['JSON'],viewFilterGroupId?: (Scalars['UUID'] | null),positionInViewFilterGroup?: (Scalars['Float'] | null),subFieldName?: (Scalars['String'] | null),viewId: Scalars['UUID']}
export interface CreateViewFilterInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],operand?: (ViewFilterOperand | null),value: Scalars['JSON'],viewFilterGroupId?: (Scalars['UUID'] | null),positionInViewFilterGroup?: (Scalars['Float'] | null),subFieldName?: (Scalars['String'] | null),relationTargetFieldMetadataId?: (Scalars['UUID'] | null),viewId: Scalars['UUID']}
export interface UpdateViewFilterInput {
/** The id of the view filter to update */
@@ -5981,7 +5983,7 @@ id: Scalars['UUID'],
/** The view filter to update */
update: UpdateViewFilterInputUpdates}
export interface UpdateViewFilterInputUpdates {fieldMetadataId?: (Scalars['UUID'] | null),operand?: (ViewFilterOperand | null),value?: (Scalars['JSON'] | null),viewFilterGroupId?: (Scalars['UUID'] | null),positionInViewFilterGroup?: (Scalars['Float'] | null),subFieldName?: (Scalars['String'] | null)}
export interface UpdateViewFilterInputUpdates {fieldMetadataId?: (Scalars['UUID'] | null),operand?: (ViewFilterOperand | null),value?: (Scalars['JSON'] | null),viewFilterGroupId?: (Scalars['UUID'] | null),positionInViewFilterGroup?: (Scalars['Float'] | null),subFieldName?: (Scalars['String'] | null),relationTargetFieldMetadataId?: (Scalars['UUID'] | null)}
export interface DeleteViewFilterInput {
/** The id of the view filter to delete. */
@@ -6013,7 +6015,7 @@ viewFieldId?: (Scalars['UUID'] | null),
/** The field metadata id. Used to create a new view field when viewFieldId is not provided. */
fieldMetadataId?: (Scalars['UUID'] | null),isVisible: Scalars['Boolean'],position: Scalars['Float'],size?: (Scalars['Float'] | null)}
export interface UpsertViewWidgetViewFilterInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],operand?: (ViewFilterOperand | null),value: Scalars['JSON'],viewFilterGroupId?: (Scalars['UUID'] | null),positionInViewFilterGroup?: (Scalars['Float'] | null),subFieldName?: (Scalars['String'] | null)}
export interface UpsertViewWidgetViewFilterInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],operand?: (ViewFilterOperand | null),value: Scalars['JSON'],viewFilterGroupId?: (Scalars['UUID'] | null),positionInViewFilterGroup?: (Scalars['Float'] | null),subFieldName?: (Scalars['String'] | null),relationTargetFieldMetadataId?: (Scalars['UUID'] | null)}
export interface UpsertViewWidgetViewFilterGroupInput {id?: (Scalars['UUID'] | null),parentViewFilterGroupId?: (Scalars['UUID'] | null),logicalOperator?: (ViewFilterGroupLogicalOperator | null),positionInViewFilterGroup?: (Scalars['Float'] | null)}
@@ -1521,6 +1521,9 @@ export default {
"subFieldName": [
1
],
"relationTargetFieldMetadataId": [
3
],
"viewId": [
3
],
@@ -8929,6 +8932,9 @@ export default {
"subFieldName": [
1
],
"relationTargetFieldMetadataId": [
3
],
"viewId": [
3
],
@@ -8966,6 +8972,9 @@ export default {
"subFieldName": [
1
],
"relationTargetFieldMetadataId": [
3
],
"__typename": [
1
]
@@ -9154,6 +9163,9 @@ export default {
"subFieldName": [
1
],
"relationTargetFieldMetadataId": [
3
],
"__typename": [
1
]
File diff suppressed because one or more lines are too long
@@ -89,12 +89,17 @@ export const buildHeadlessCommandContextApi = ({
? (currentWorkspaceMember?.timeZone ?? systemTimeZone)
: systemTimeZone;
const flattenedFieldMetadataItems = objectMetadataItems.flatMap(
(objectMetadataItem) => objectMetadataItem.fields,
);
const graphqlFilter = isDefined(objectMetadataItem)
? computeContextStoreFilters({
contextStoreTargetedRecordsRule: targetedRecordsRule,
contextStoreFilters: filters,
contextStoreFilterGroups: filterGroups,
objectMetadataItem,
flattenedFieldMetadataItems,
filterValueDependencies: {
currentWorkspaceMemberId: currentWorkspaceMember?.id,
timeZone: userTimezone,
@@ -72,6 +72,7 @@ export const useFindManyRecordsSelectedInContextStore = ({
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
flattenedFieldMetadataItems: allFieldMetadataItems,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
});
@@ -29,6 +29,7 @@ describe('computeContextStoreFilters', () => {
contextStoreFilters: [],
contextStoreFilterGroups: [],
objectMetadataItem: personObjectMetadataItem,
flattenedFieldMetadataItems: personObjectMetadataItem.fields,
filterValueDependencies: mockFilterValueDependencies,
contextStoreAnyFieldFilterValue: '',
});
@@ -73,6 +74,7 @@ describe('computeContextStoreFilters', () => {
contextStoreFilters,
contextStoreFilterGroups: [],
objectMetadataItem: personObjectMetadataItem,
flattenedFieldMetadataItems: personObjectMetadataItem.fields,
filterValueDependencies: mockFilterValueDependencies,
contextStoreAnyFieldFilterValue: '',
});
@@ -1,7 +1,9 @@
import { type ContextStoreTargetedRecordsRule } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables';
import {
type RecordFilterValueDependencies,
@@ -17,6 +19,7 @@ type ComputeContextStoreFiltersProps = {
contextStoreFilters: RecordFilter[];
contextStoreFilterGroups: RecordFilterGroup[];
objectMetadataItem: EnrichedObjectMetadataItem;
flattenedFieldMetadataItems: FieldMetadataItem[];
filterValueDependencies: RecordFilterValueDependencies;
contextStoreAnyFieldFilterValue: string;
};
@@ -26,6 +29,7 @@ export const computeContextStoreFilters = ({
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
flattenedFieldMetadataItems,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
}: ComputeContextStoreFiltersProps) => {
@@ -37,12 +41,18 @@ export const computeContextStoreFilters = ({
fields: objectMetadataItem.fields,
});
const fields = augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem?.fields ?? [],
recordFilters: contextStoreFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
});
if (contextStoreTargetedRecordsRule.mode === 'exclusion') {
queryFilter = makeAndFilterVariables([
recordGqlFilterForAnyFieldFilter,
computeRecordGqlOperationFilter({
filterValueDependencies,
fields: objectMetadataItem?.fields ?? [],
fields,
recordFilters: contextStoreFilters,
recordFilterGroups: contextStoreFilterGroups,
}),
@@ -71,7 +81,7 @@ export const computeContextStoreFilters = ({
},
computeRecordGqlOperationFilter({
filterValueDependencies,
fields: objectMetadataItem?.fields ?? [],
fields,
recordFilters: contextStoreFilters,
recordFilterGroups: contextStoreFilterGroups,
}),
@@ -1,6 +1,10 @@
import { SORTABLE_FIELD_METADATA_TYPES } from '@/object-metadata/constants/SortableFieldMetadataTypes';
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField';
import {
type FieldMetadataType,
type RelationType,
} from '~/generated-metadata/graphql';
type SortableFieldInput = {
isSystem?: boolean | null;
@@ -17,13 +21,9 @@ export const filterSortableFieldMetadataItems = (field: SortableFieldInput) => {
field.type,
);
const isRelationFieldSortable =
field.type === FieldMetadataType.RELATION &&
field.relation?.type === RelationType.MANY_TO_ONE;
return (
!isHiddenSystemField(field) &&
isFieldActive &&
(isFieldTypeSortable || isRelationFieldSortable)
(isFieldTypeSortable || isManyToOneRelationField(field))
);
};
@@ -0,0 +1,12 @@
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
type FieldWithRelation = {
type: FieldMetadataType;
relation?: { type: RelationType } | null;
};
export const isManyToOneRelationField = <T extends FieldWithRelation>(
field: T,
): field is T & { relation: NonNullable<T['relation']> } =>
field.type === FieldMetadataType.RELATION &&
field.relation?.type === RelationType.MANY_TO_ONE;
@@ -1,13 +1,6 @@
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { isDefined } from 'twenty-shared/utils';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterFieldSelectDropdown';
import { useSelectFieldUsedInAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown';
import { useApplyAdvancedFilterCompositeSubField } from '@/object-record/advanced-filter/hooks/useApplyAdvancedFilterCompositeSubField';
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState';
import { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState';
@@ -20,21 +13,26 @@ import { type CompositeFieldSubFieldName } from '@/settings/data-model/types/Com
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronLeft, useIcons } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
type AdvancedFilterSubFieldSelectMenuProps = {
type AdvancedFilterCompositeSubFieldSelectMenuProps = {
recordFilterId: string;
};
export const AdvancedFilterSubFieldSelectMenu = ({
export const AdvancedFilterCompositeSubFieldSelectMenu = ({
recordFilterId,
}: AdvancedFilterSubFieldSelectMenuProps) => {
}: AdvancedFilterCompositeSubFieldSelectMenuProps) => {
const { getIcon } = useIcons();
const fieldMetadataItemUsedInDropdown = useAtomComponentSelectorValue(
@@ -54,21 +52,20 @@ export const AdvancedFilterSubFieldSelectMenu = ({
const { closeAdvancedFilterFieldSelectDropdown } =
useAdvancedFilterFieldSelectDropdown(recordFilterId);
const { selectFieldUsedInAdvancedFilterDropdown } =
useSelectFieldUsedInAdvancedFilterDropdown();
const { applyAdvancedFilterCompositeSubField } =
useApplyAdvancedFilterCompositeSubField();
const handleSelectFilter = (
selectedFieldMetadataItem: FieldMetadataItem | null | undefined,
subFieldName?: CompositeFieldSubFieldName | null | undefined,
) => {
if (!isDefined(selectedFieldMetadataItem)) {
return;
}
selectFieldUsedInAdvancedFilterDropdown({
fieldMetadataItemId: selectedFieldMetadataItem.id,
const handleSelectFilter = ({
fieldMetadataItem,
subFieldName,
}: {
fieldMetadataItem: FieldMetadataItem;
subFieldName?: CompositeFieldSubFieldName | null;
}) => {
applyAdvancedFilterCompositeSubField({
sourceFieldMetadataItem: fieldMetadataItem,
subFieldName: subFieldName ?? null,
recordFilterId,
subFieldName,
});
closeAdvancedFilterFieldSelectDropdown();
@@ -132,36 +129,42 @@ export const AdvancedFilterSubFieldSelectMenu = ({
selectableItemIdArray={selectableItemIdArray}
selectableListInstanceId={advancedFilterFieldSelectDropdownId}
>
{compositeFieldTypeIsFilterableByAnySubField && (
<SelectableListItem
itemId="-1"
key={`select-filter-${-1}`}
onEnter={() => {
handleSelectFilter(fieldMetadataItemUsedInDropdown);
}}
>
<MenuItem
{compositeFieldTypeIsFilterableByAnySubField &&
isDefined(fieldMetadataItemUsedInDropdown) && (
<SelectableListItem
itemId="-1"
key={`select-filter-${-1}`}
testId={`select-filter-${-1}`}
focused={selectedItemId === '-1'}
onClick={() => {
handleSelectFilter(fieldMetadataItemUsedInDropdown);
onEnter={() => {
handleSelectFilter({
fieldMetadataItem: fieldMetadataItemUsedInDropdown,
});
}}
LeftIcon={getIcon(fieldMetadataItemUsedInDropdown.icon)}
text={t`Any ${fieldLabel} field`}
/>
</SelectableListItem>
)}
>
<MenuItem
key={`select-filter-${-1}`}
testId={`select-filter-${-1}`}
focused={selectedItemId === '-1'}
onClick={() => {
handleSelectFilter({
fieldMetadataItem: fieldMetadataItemUsedInDropdown,
});
}}
LeftIcon={getIcon(fieldMetadataItemUsedInDropdown.icon)}
text={t`Any ${fieldLabel} field`}
/>
</SelectableListItem>
)}
{subFieldsAreFilterable &&
isDefined(fieldMetadataItemUsedInDropdown) &&
subFieldNames.map((subFieldName, index) => (
<SelectableListItem
itemId={subFieldName}
key={`select-filter-${index}`}
onEnter={() => {
handleSelectFilter(
fieldMetadataItemUsedInDropdown,
handleSelectFilter({
fieldMetadataItem: fieldMetadataItemUsedInDropdown,
subFieldName,
);
});
}}
>
<MenuItem
@@ -169,12 +172,10 @@ export const AdvancedFilterSubFieldSelectMenu = ({
key={`select-filter-${index}`}
testId={`select-filter-${index}`}
onClick={() => {
if (isDefined(fieldMetadataItemUsedInDropdown)) {
handleSelectFilter(
fieldMetadataItemUsedInDropdown,
subFieldName,
);
}
handleSelectFilter({
fieldMetadataItem: fieldMetadataItemUsedInDropdown,
subFieldName,
});
}}
text={getCompositeSubFieldLabel(
objectFilterDropdownSubMenuFieldType,
@@ -182,7 +183,7 @@ export const AdvancedFilterSubFieldSelectMenu = ({
)}
LeftIcon={getIcon(
ICON_NAME_BY_SUB_FIELD[subFieldName] ??
fieldMetadataItemUsedInDropdown?.icon,
fieldMetadataItemUsedInDropdown.icon,
)}
/>
</SelectableListItem>
@@ -1,7 +1,9 @@
import { AdvancedFilterCompositeSubFieldSelectMenu } from '@/object-record/advanced-filter/components/AdvancedFilterCompositeSubFieldSelectMenu';
import { AdvancedFilterFieldSelectMenu } from '@/object-record/advanced-filter/components/AdvancedFilterFieldSelectMenu';
import { AdvancedFilterSubFieldSelectMenu } from '@/object-record/advanced-filter/components/AdvancedFilterSubFieldSelectMenu';
import { AdvancedFilterRelationTargetFieldSelectMenu } from '@/object-record/advanced-filter/components/AdvancedFilterRelationTargetFieldSelectMenu';
import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { objectFilterDropdownIsSelectingRelationTargetFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingRelationTargetFieldComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
type AdvancedFilterFieldSelectDropdownContentProps = {
recordFilterId: string;
@@ -10,16 +12,31 @@ type AdvancedFilterFieldSelectDropdownContentProps = {
export const AdvancedFilterFieldSelectDropdownContent = ({
recordFilterId,
}: AdvancedFilterFieldSelectDropdownContentProps) => {
const [objectFilterDropdownIsSelectingCompositeField] = useAtomComponentState(
objectFilterDropdownIsSelectingCompositeFieldComponentState,
);
const objectFilterDropdownIsSelectingCompositeField =
useAtomComponentStateValue(
objectFilterDropdownIsSelectingCompositeFieldComponentState,
);
const shouldShowCompositeSelectionSubMenu =
objectFilterDropdownIsSelectingCompositeField;
const objectFilterDropdownIsSelectingRelationTargetField =
useAtomComponentStateValue(
objectFilterDropdownIsSelectingRelationTargetFieldComponentState,
);
return shouldShowCompositeSelectionSubMenu ? (
<AdvancedFilterSubFieldSelectMenu recordFilterId={recordFilterId} />
) : (
<AdvancedFilterFieldSelectMenu recordFilterId={recordFilterId} />
);
if (objectFilterDropdownIsSelectingRelationTargetField) {
return (
<AdvancedFilterRelationTargetFieldSelectMenu
recordFilterId={recordFilterId}
/>
);
}
if (objectFilterDropdownIsSelectingCompositeField) {
return (
<AdvancedFilterCompositeSubFieldSelectMenu
recordFilterId={recordFilterId}
/>
);
}
return <AdvancedFilterFieldSelectMenu recordFilterId={recordFilterId} />;
};
@@ -11,17 +11,20 @@ import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { AdvancedFilterFieldSelectSearchInput } from '@/object-record/advanced-filter/components/AdvancedFilterFieldSelectSearchInput';
import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterFieldSelectDropdown';
import { useSelectFieldUsedInAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown';
import { useApplyAdvancedFilterSourceField } from '@/object-record/advanced-filter/hooks/useApplyAdvancedFilterSourceField';
import { AdvancedFilterContext } from '@/object-record/advanced-filter/states/context/AdvancedFilterContext';
import { ObjectFilterDropdownFilterSelectMenuItem } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectMenuItem';
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState';
import { objectFilterDropdownIsSelectingRelationTargetFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingRelationTargetFieldComponentState';
import { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState';
import { isCompositeFilterableFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFilterableFieldType';
import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField';
import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector';
import { useFilterableFieldMetadataItems } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItems';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuSectionLabel } from '@/ui/layout/dropdown/components/DropdownMenuSectionLabel';
import { usePushFocusForLeafFieldValuePicker } from '@/object-record/advanced-filter/hooks/usePushFocusForLeafFieldValuePicker';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
@@ -87,8 +90,8 @@ export const AdvancedFilterFieldSelectMenu = ({
advancedFilterFieldSelectDropdownId,
);
const { selectFieldUsedInAdvancedFilterDropdown } =
useSelectFieldUsedInAdvancedFilterDropdown();
const { applyAdvancedFilterSourceField } =
useApplyAdvancedFilterSourceField();
const [, setObjectFilterDropdownSubMenuFieldType] = useAtomComponentState(
objectFilterDropdownSubMenuFieldTypeComponentState,
@@ -99,10 +102,18 @@ export const AdvancedFilterFieldSelectMenu = ({
objectFilterDropdownIsSelectingCompositeFieldComponentState,
);
const [, setObjectFilterDropdownIsSelectingRelationTargetField] =
useAtomComponentState(
objectFilterDropdownIsSelectingRelationTargetFieldComponentState,
);
const setFieldMetadataItemIdUsedInDropdown = useSetAtomComponentState(
fieldMetadataItemIdUsedInDropdownComponentState,
);
const { pushFocusForLeafFieldValuePicker } =
usePushFocusForLeafFieldValuePicker();
const handleFieldMetadataItemSelect = (
selectedFieldMetadataItem: FieldMetadataItem,
) => {
@@ -112,19 +123,41 @@ export const AdvancedFilterFieldSelectMenu = ({
selectedFieldMetadataItem.type,
);
selectFieldUsedInAdvancedFilterDropdown({
fieldMetadataItemId: selectedFieldMetadataItem.id,
const isRelationTraversalField = isManyToOneRelationField(
selectedFieldMetadataItem,
);
const compositeSubMenuFieldType =
!isRelationTraversalField && isCompositeFilterableFieldType(filterType)
? filterType
: null;
// For sub-menu paths (composite or relation traversal) we only stage
// the source field in the dropdown state and open the sub-menu —
// upsertRecordFilter happens in the sub-menu's hook once the user
// makes the final choice. Otherwise navigating back from the sub-menu
// would leave an orphan partial filter in the chip list.
if (isRelationTraversalField) {
setFieldMetadataItemIdUsedInDropdown(selectedFieldMetadataItem.id);
setObjectFilterDropdownIsSelectingRelationTargetField(true);
return;
}
if (compositeSubMenuFieldType !== null) {
setFieldMetadataItemIdUsedInDropdown(selectedFieldMetadataItem.id);
setObjectFilterDropdownSubMenuFieldType(compositeSubMenuFieldType);
setObjectFilterDropdownIsSelectingCompositeField(true);
return;
}
applyAdvancedFilterSourceField({
sourceFieldMetadataItem: selectedFieldMetadataItem,
recordFilterId,
});
if (isCompositeFilterableFieldType(filterType)) {
setObjectFilterDropdownSubMenuFieldType(filterType);
pushFocusForLeafFieldValuePicker(selectedFieldMetadataItem);
setFieldMetadataItemIdUsedInDropdown(selectedFieldMetadataItem.id);
setObjectFilterDropdownIsSelectingCompositeField(true);
} else {
closeAdvancedFilterFieldSelectDropdown();
}
closeAdvancedFilterFieldSelectDropdown();
};
const shouldShowVisibleFields = visibleFieldMetadataItems.length > 0;
@@ -0,0 +1,146 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField';
import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterFieldSelectDropdown';
import { useApplyAdvancedFilterRelationTargetField } from '@/object-record/advanced-filter/hooks/useApplyAdvancedFilterRelationTargetField';
import { usePushFocusForLeafFieldValuePicker } from '@/object-record/advanced-filter/hooks/usePushFocusForLeafFieldValuePicker';
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { objectFilterDropdownIsSelectingRelationTargetFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingRelationTargetFieldComponentState';
import { useFilterableFieldMetadataItems } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItems';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronLeft, useIcons } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
type AdvancedFilterRelationTargetFieldSelectMenuProps = {
recordFilterId: string;
};
export const AdvancedFilterRelationTargetFieldSelectMenu = ({
recordFilterId,
}: AdvancedFilterRelationTargetFieldSelectMenuProps) => {
const { getIcon } = useIcons();
const sourceFieldMetadataItem = useAtomComponentSelectorValue(
fieldMetadataItemUsedInDropdownComponentSelector,
);
const setObjectFilterDropdownIsSelectingRelationTargetField =
useSetAtomComponentState(
objectFilterDropdownIsSelectingRelationTargetFieldComponentState,
);
const { closeAdvancedFilterFieldSelectDropdown } =
useAdvancedFilterFieldSelectDropdown(recordFilterId);
const { applyAdvancedFilterRelationTargetField } =
useApplyAdvancedFilterRelationTargetField();
const { pushFocusForLeafFieldValuePicker } =
usePushFocusForLeafFieldValuePicker();
const { advancedFilterFieldSelectDropdownId } =
useAdvancedFilterFieldSelectDropdown(recordFilterId);
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
advancedFilterFieldSelectDropdownId,
);
const targetObjectMetadataId =
isDefined(sourceFieldMetadataItem) &&
isManyToOneRelationField(sourceFieldMetadataItem)
? sourceFieldMetadataItem.relation.targetObjectMetadata.id
: null;
const { filterableFieldMetadataItems: allTargetFields } =
useFilterableFieldMetadataItems(targetObjectMetadataId ?? '');
// The backend supports a single hop only. Exclude many-to-one relations
// from the target list so the user can't compose multi-hop traversals
// (e.g. Person → Company → ParentCompany) that the dispatcher would
// collapse back to a filter-by-id on the intermediate relation.
const relationTargetFields = allTargetFields.filter(
(field) => !isManyToOneRelationField(field),
);
if (
!isDefined(sourceFieldMetadataItem) ||
!isManyToOneRelationField(sourceFieldMetadataItem)
) {
return null;
}
const handleSubMenuBack = () => {
setObjectFilterDropdownIsSelectingRelationTargetField(false);
};
const handleSelectTargetField = (
relationTargetFieldMetadataItem: FieldMetadataItem,
) => {
applyAdvancedFilterRelationTargetField({
sourceFieldMetadataItem,
relationTargetFieldMetadataItem,
recordFilterId,
});
pushFocusForLeafFieldValuePicker(relationTargetFieldMetadataItem);
setObjectFilterDropdownIsSelectingRelationTargetField(false);
closeAdvancedFilterFieldSelectDropdown();
};
const selectableItemIdArray = relationTargetFields.map((field) => field.id);
return (
<DropdownContent widthInPixels={GenericDropdownContentWidth.ExtraLarge}>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={handleSubMenuBack}
Icon={IconChevronLeft}
/>
}
>
{sourceFieldMetadataItem.label}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
<SelectableList
focusId={advancedFilterFieldSelectDropdownId}
selectableItemIdArray={selectableItemIdArray}
selectableListInstanceId={advancedFilterFieldSelectDropdownId}
>
{relationTargetFields.map((targetField, index) => (
<SelectableListItem
itemId={targetField.id}
key={`select-filter-relation-${index}`}
onEnter={() => {
handleSelectTargetField(targetField);
}}
>
<MenuItem
focused={selectedItemId === targetField.id}
key={`select-filter-relation-${index}`}
testId={`select-filter-relation-${index}`}
onClick={() => {
handleSelectTargetField(targetField);
}}
text={targetField.label}
LeftIcon={getIcon(targetField.icon)}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -6,6 +6,7 @@ import { shouldShowFilterTextInput } from '@/object-record/advanced-filter/utils
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
import { relationTargetFieldMetadataIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/relationTargetFieldMetadataIdUsedInDropdownComponentState';
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
import { configurableViewFilterOperands } from '@/object-record/object-filter-dropdown/utils/configurableViewFilterOperands';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
@@ -55,6 +56,12 @@ export const AdvancedFilterValueInput = ({
dropdownId,
);
const setRelationTargetFieldMetadataIdUsedInDropdown =
useSetAtomComponentState(
relationTargetFieldMetadataIdUsedInDropdownComponentState,
dropdownId,
);
const setObjectFilterDropdownCurrentRecordFilter = useSetAtomComponentState(
objectFilterDropdownCurrentRecordFilterComponentState,
dropdownId,
@@ -74,6 +81,9 @@ export const AdvancedFilterValueInput = ({
const handleFilterValueDropdownOpen = () => {
setObjectFilterDropdownCurrentRecordFilter(recordFilter);
setFieldMetadataItemIdUsedInDropdown(recordFilter.fieldMetadataId);
setRelationTargetFieldMetadataIdUsedInDropdown(
recordFilter.relationTargetFieldMetadataId ?? null,
);
};
const filterType = recordFilter.type;
@@ -1,31 +1,30 @@
import { useGetFieldMetadataItemByIdOrThrow } from '@/object-metadata/hooks/useGetFieldMetadataItemById';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useGetInitialFilterValue } from '@/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue';
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
import { relationTargetFieldMetadataIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/relationTargetFieldMetadataIdUsedInDropdownComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { getDefaultSubFieldNameForCompositeFilterableFieldType } from '@/object-record/record-filter/utils/getDefaultSubFieldNameForCompositeFilterableFieldType';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { isCompositeTypeNonFilterableByAnySubField } from '@/object-record/record-filter/utils/isCompositeTypeNonFilterableByAnySubField';
import { type CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
type SelectFilterParams = {
fieldMetadataItemId: string;
type ApplyAdvancedFilterCompositeSubFieldParams = {
sourceFieldMetadataItem: FieldMetadataItem;
subFieldName: CompositeFieldSubFieldName | null;
recordFilterId: string;
subFieldName?: CompositeFieldSubFieldName | null | undefined;
};
export const useSelectFieldUsedInAdvancedFilterDropdown = () => {
// Creates an advanced filter from a composite source field together with
// the user's chosen sub-field (e.g. address city, name first name). The
// filter type is still driven by the source field's composite type.
export const useApplyAdvancedFilterCompositeSubField = () => {
const setSelectedOperandInDropdown = useSetAtomComponentState(
selectedOperandInDropdownComponentState,
);
@@ -38,54 +37,34 @@ export const useSelectFieldUsedInAdvancedFilterDropdown = () => {
objectFilterDropdownSearchInputComponentState,
);
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
);
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { getFieldMetadataItemByIdOrThrow } =
useGetFieldMetadataItemByIdOrThrow();
const setSubFieldNameUsedInDropdown = useSetAtomComponentState(
subFieldNameUsedInDropdownComponentState,
);
const setRelationTargetFieldMetadataIdUsedInDropdown =
useSetAtomComponentState(
relationTargetFieldMetadataIdUsedInDropdownComponentState,
);
const setObjectFilterDropdownCurrentRecordFilter = useSetAtomComponentState(
objectFilterDropdownCurrentRecordFilterComponentState,
);
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
);
const { upsertRecordFilter } = useUpsertRecordFilter();
const { getInitialFilterValue } = useGetInitialFilterValue();
const selectFieldUsedInAdvancedFilterDropdown = ({
fieldMetadataItemId,
recordFilterId,
const applyAdvancedFilterCompositeSubField = ({
sourceFieldMetadataItem,
subFieldName,
}: SelectFilterParams) => {
setFieldMetadataItemIdUsedInDropdown(fieldMetadataItemId);
recordFilterId,
}: ApplyAdvancedFilterCompositeSubFieldParams) => {
setFieldMetadataItemIdUsedInDropdown(sourceFieldMetadataItem.id);
const { fieldMetadataItem } =
getFieldMetadataItemByIdOrThrow(fieldMetadataItemId);
if (!isDefined(fieldMetadataItem)) {
return;
}
if (
fieldMetadataItem.type === 'RELATION' ||
fieldMetadataItem.type === 'SELECT'
) {
pushFocusItemToFocusStack({
focusId: fieldMetadataItem.id,
component: {
type: FocusComponentType.DROPDOWN,
instanceId: fieldMetadataItem.id,
},
});
}
const filterType = getFilterTypeFromFieldType(fieldMetadataItem.type);
const filterType = getFilterTypeFromFieldType(sourceFieldMetadataItem.type);
const firstOperand = getRecordFilterOperands({
filterType,
@@ -109,30 +88,9 @@ export const useSelectFieldUsedInAdvancedFilterDropdown = () => {
(recordFilter) => recordFilter.id === recordFilterId,
);
const isCompositeFilterOnAnySubField =
isCompositeFieldType(filterType) && !isDefined(subFieldName);
const compositeFilterNonFilterableByAnySubField =
isCompositeTypeNonFilterableByAnySubField(filterType);
let subFieldNameForNonFilterableWithAny:
| CompositeFieldSubFieldName
| undefined
| null = subFieldName;
if (
isCompositeFilterOnAnySubField &&
compositeFilterNonFilterableByAnySubField
) {
subFieldNameForNonFilterableWithAny =
getDefaultSubFieldNameForCompositeFilterableFieldType(filterType);
}
const subFieldNameToUse =
subFieldName ?? subFieldNameForNonFilterableWithAny;
const newAdvancedFilter = {
id: recordFilterId,
fieldMetadataId: fieldMetadataItem.id,
fieldMetadataId: sourceFieldMetadataItem.id,
displayValue,
operand: firstOperand,
value,
@@ -140,11 +98,13 @@ export const useSelectFieldUsedInAdvancedFilterDropdown = () => {
positionInRecordFilterGroup:
existingRecordFilter?.positionInRecordFilterGroup,
type: filterType,
label: fieldMetadataItem.label,
subFieldName: subFieldNameToUse,
label: sourceFieldMetadataItem.label,
subFieldName,
relationTargetFieldMetadataId: null,
} satisfies RecordFilter;
setSubFieldNameUsedInDropdown(subFieldNameToUse);
setSubFieldNameUsedInDropdown(subFieldName);
setRelationTargetFieldMetadataIdUsedInDropdown(null);
setObjectFilterDropdownSearchInput('');
@@ -153,6 +113,6 @@ export const useSelectFieldUsedInAdvancedFilterDropdown = () => {
};
return {
selectFieldUsedInAdvancedFilterDropdown,
applyAdvancedFilterCompositeSubField,
};
};
@@ -0,0 +1,122 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useGetInitialFilterValue } from '@/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue';
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
import { relationTargetFieldMetadataIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/relationTargetFieldMetadataIdUsedInDropdownComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
type ApplyAdvancedFilterRelationTargetFieldParams = {
sourceFieldMetadataItem: FieldMetadataItem;
relationTargetFieldMetadataItem: FieldMetadataItem;
recordFilterId: string;
};
// Creates an advanced filter that traverses a relation: the source is the
// relation field, the filter operates on a target field of the related
// object. Filter type comes from the target field; the label combines
// both labels so the chip reads "Source → Target".
export const useApplyAdvancedFilterRelationTargetField = () => {
const setSelectedOperandInDropdown = useSetAtomComponentState(
selectedOperandInDropdownComponentState,
);
const setFieldMetadataItemIdUsedInDropdown = useSetAtomComponentState(
fieldMetadataItemIdUsedInDropdownComponentState,
);
const setObjectFilterDropdownSearchInput = useSetAtomComponentState(
objectFilterDropdownSearchInputComponentState,
);
const setSubFieldNameUsedInDropdown = useSetAtomComponentState(
subFieldNameUsedInDropdownComponentState,
);
const setRelationTargetFieldMetadataIdUsedInDropdown =
useSetAtomComponentState(
relationTargetFieldMetadataIdUsedInDropdownComponentState,
);
const setObjectFilterDropdownCurrentRecordFilter = useSetAtomComponentState(
objectFilterDropdownCurrentRecordFilterComponentState,
);
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
);
const { upsertRecordFilter } = useUpsertRecordFilter();
const { getInitialFilterValue } = useGetInitialFilterValue();
const applyAdvancedFilterRelationTargetField = ({
sourceFieldMetadataItem,
relationTargetFieldMetadataItem,
recordFilterId,
}: ApplyAdvancedFilterRelationTargetFieldParams) => {
setFieldMetadataItemIdUsedInDropdown(sourceFieldMetadataItem.id);
const filterType = getFilterTypeFromFieldType(
relationTargetFieldMetadataItem.type,
);
const firstOperand = getRecordFilterOperands({
filterType,
subFieldName: null,
})?.[0];
if (!isDefined(firstOperand)) {
throw new Error(
`No valid operand found for relation-traversal filter type: ${filterType}`,
);
}
setSelectedOperandInDropdown(firstOperand);
const { value, displayValue } = getInitialFilterValue(
filterType,
firstOperand,
);
const existingRecordFilter = currentRecordFilters.find(
(recordFilter) => recordFilter.id === recordFilterId,
);
const newAdvancedFilter = {
id: recordFilterId,
fieldMetadataId: sourceFieldMetadataItem.id,
displayValue,
operand: firstOperand,
value,
recordFilterGroupId: existingRecordFilter?.recordFilterGroupId,
positionInRecordFilterGroup:
existingRecordFilter?.positionInRecordFilterGroup,
type: filterType,
label: `${sourceFieldMetadataItem.label}${relationTargetFieldMetadataItem.label}`,
subFieldName: null,
relationTargetFieldMetadataId: relationTargetFieldMetadataItem.id,
} satisfies RecordFilter;
setSubFieldNameUsedInDropdown(null);
setRelationTargetFieldMetadataIdUsedInDropdown(
relationTargetFieldMetadataItem.id,
);
setObjectFilterDropdownSearchInput('');
setObjectFilterDropdownCurrentRecordFilter(newAdvancedFilter);
upsertRecordFilter(newAdvancedFilter);
};
return {
applyAdvancedFilterRelationTargetField,
};
};
@@ -0,0 +1,115 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useGetInitialFilterValue } from '@/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue';
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
import { relationTargetFieldMetadataIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/relationTargetFieldMetadataIdUsedInDropdownComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
type ApplyAdvancedFilterSourceFieldParams = {
sourceFieldMetadataItem: FieldMetadataItem;
recordFilterId: string;
};
// Creates a leaf advanced filter from a source field selection. Composite
// and many-to-one relation sources are handled by their own specialized
// hooks (useApplyAdvancedFilterCompositeSubField,
// useApplyAdvancedFilterRelationTargetField) — callers branch to those
// before reaching this hook.
export const useApplyAdvancedFilterSourceField = () => {
const setSelectedOperandInDropdown = useSetAtomComponentState(
selectedOperandInDropdownComponentState,
);
const setFieldMetadataItemIdUsedInDropdown = useSetAtomComponentState(
fieldMetadataItemIdUsedInDropdownComponentState,
);
const setObjectFilterDropdownSearchInput = useSetAtomComponentState(
objectFilterDropdownSearchInputComponentState,
);
const setSubFieldNameUsedInDropdown = useSetAtomComponentState(
subFieldNameUsedInDropdownComponentState,
);
const setRelationTargetFieldMetadataIdUsedInDropdown =
useSetAtomComponentState(
relationTargetFieldMetadataIdUsedInDropdownComponentState,
);
const setObjectFilterDropdownCurrentRecordFilter = useSetAtomComponentState(
objectFilterDropdownCurrentRecordFilterComponentState,
);
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
);
const { upsertRecordFilter } = useUpsertRecordFilter();
const { getInitialFilterValue } = useGetInitialFilterValue();
const applyAdvancedFilterSourceField = ({
sourceFieldMetadataItem,
recordFilterId,
}: ApplyAdvancedFilterSourceFieldParams) => {
setFieldMetadataItemIdUsedInDropdown(sourceFieldMetadataItem.id);
const filterType = getFilterTypeFromFieldType(sourceFieldMetadataItem.type);
const firstOperand = getRecordFilterOperands({
filterType,
subFieldName: null,
})?.[0];
if (!isDefined(firstOperand)) {
throw new Error(`No valid operand found for filter type: ${filterType}`);
}
setSelectedOperandInDropdown(firstOperand);
const { value, displayValue } = getInitialFilterValue(
filterType,
firstOperand,
);
const existingRecordFilter = currentRecordFilters.find(
(recordFilter) => recordFilter.id === recordFilterId,
);
const newAdvancedFilter = {
id: recordFilterId,
fieldMetadataId: sourceFieldMetadataItem.id,
displayValue,
operand: firstOperand,
value,
recordFilterGroupId: existingRecordFilter?.recordFilterGroupId,
positionInRecordFilterGroup:
existingRecordFilter?.positionInRecordFilterGroup,
type: filterType,
label: sourceFieldMetadataItem.label,
subFieldName: null,
relationTargetFieldMetadataId: null,
} satisfies RecordFilter;
setSubFieldNameUsedInDropdown(null);
setRelationTargetFieldMetadataIdUsedInDropdown(null);
setObjectFilterDropdownSearchInput('');
setObjectFilterDropdownCurrentRecordFilter(newAdvancedFilter);
upsertRecordFilter(newAdvancedFilter);
};
return {
applyAdvancedFilterSourceField,
};
};
@@ -0,0 +1,32 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
// RELATION and SELECT leaf fields open a value picker keyed by the field
// id once the user finishes choosing the field — push it on the focus
// stack so the picker's keyboard hotkeys are active when it opens. Other
// field types use input-style pickers that don't rely on the focus stack.
export const usePushFocusForLeafFieldValuePicker = () => {
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const pushFocusForLeafFieldValuePicker = (
fieldMetadataItem: FieldMetadataItem,
) => {
if (
fieldMetadataItem.type !== 'RELATION' &&
fieldMetadataItem.type !== 'SELECT'
) {
return;
}
pushFocusItemToFocusStack({
focusId: fieldMetadataItem.id,
component: {
type: FocusComponentType.DROPDOWN,
instanceId: fieldMetadataItem.id,
},
});
};
return { pushFocusForLeafFieldValuePicker };
};
@@ -1,6 +1,7 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FILTER_FIELD_LIST_ID } from '@/object-record/object-filter-dropdown/constants/FilterFieldListId';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdComponentFamilyState';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
@@ -27,9 +28,9 @@ export const ObjectFilterDropdownFilterSelectMenuItem = ({
const Icon = getIcon(fieldMetadataItemToSelect.icon);
const shouldShowSubMenu = isCompositeFieldType(
fieldMetadataItemToSelect.type,
);
const shouldShowSubMenu =
isCompositeFieldType(fieldMetadataItemToSelect.type) ||
isManyToOneRelationField(fieldMetadataItemToSelect);
const handleClick = () => {
resetSelectedItem();
@@ -1,7 +1,10 @@
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { getFieldMetadataItemById } from '@/object-metadata/utils/getFieldMetadataItemById';
import { DATE_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/DateFilterTypes';
import { DATE_PICKER_DROPDOWN_CONTENT_WIDTH } from '@/object-record/object-filter-dropdown/constants/DatePickerDropdownContentWidth';
import { useApplyObjectFilterDropdownOperand } from '@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand';
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { relationTargetFieldMetadataIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/relationTargetFieldMetadataIdUsedInDropdownComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
import { getOperandLabel } from '@/object-record/object-filter-dropdown/utils/getOperandLabel';
@@ -13,6 +16,7 @@ import { DropdownMenuInnerSelect } from '@/ui/layout/dropdown/components/Dropdow
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
import { type SelectOption } from 'twenty-ui/input';
@@ -32,11 +36,34 @@ export const ObjectFilterDropdownInnerSelectOperandDropdown = () => {
subFieldNameUsedInDropdownComponentState,
);
const operandsForFilterType = isDefined(fieldMetadataItemUsedInDropdown)
const relationTargetFieldMetadataIdUsedInDropdown =
useAtomComponentStateValue(
relationTargetFieldMetadataIdUsedInDropdownComponentState,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
// The target field may have been deleted from the workspace since the
// filter was saved — return null and let the parent skip rendering
// rather than throwing.
const relationTargetFieldMetadataItem = isDefined(
relationTargetFieldMetadataIdUsedInDropdown,
)
? getFieldMetadataItemById({
fieldMetadataId: relationTargetFieldMetadataIdUsedInDropdown,
objectMetadataItems,
}).fieldMetadataItem
: null;
const effectiveFieldMetadataItem = isDefined(
relationTargetFieldMetadataIdUsedInDropdown,
)
? relationTargetFieldMetadataItem
: fieldMetadataItemUsedInDropdown;
const operandsForFilterType = isDefined(effectiveFieldMetadataItem)
? getRecordFilterOperands({
filterType: getFilterTypeFromFieldType(
fieldMetadataItemUsedInDropdown.type,
),
filterType: getFilterTypeFromFieldType(effectiveFieldMetadataItem.type),
subFieldName: subFieldNameUsedInDropdown,
})
: [];
@@ -70,13 +97,13 @@ export const ObjectFilterDropdownInnerSelectOperandDropdown = () => {
if (
!isDefined(selectedOperandInDropdown) ||
!isDefined(fieldMetadataItemUsedInDropdown)
!isDefined(effectiveFieldMetadataItem)
) {
return null;
}
const filterType = getFilterTypeFromFieldType(
fieldMetadataItemUsedInDropdown.type,
effectiveFieldMetadataItem.type,
);
const isDateFilter = DATE_FILTER_TYPES.includes(filterType);
@@ -5,6 +5,7 @@ import { objectFilterDropdownAnyFieldSearchIsSelectedComponentState } from '@/ob
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
import { objectFilterDropdownFilterIsSelectedComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownFilterIsSelectedComponentState';
import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState';
import { objectFilterDropdownIsSelectingRelationTargetFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingRelationTargetFieldComponentState';
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
@@ -45,6 +46,12 @@ export const useResetFilterDropdown = (componentInstanceId?: string) => {
componentInstanceId,
);
const objectFilterDropdownIsSelectingRelationTargetField =
useAtomComponentStateCallbackState(
objectFilterDropdownIsSelectingRelationTargetFieldComponentState,
componentInstanceId,
);
const objectFilterDropdownCurrentRecordFilter =
useAtomComponentStateCallbackState(
objectFilterDropdownCurrentRecordFilterComponentState,
@@ -56,6 +63,7 @@ export const useResetFilterDropdown = (componentInstanceId?: string) => {
store.set(selectedOperandInDropdown, null);
store.set(objectFilterDropdownFilterIsSelected, false);
store.set(objectFilterDropdownIsSelectingCompositeField, false);
store.set(objectFilterDropdownIsSelectingRelationTargetField, false);
store.set(fieldMetadataItemIdUsedInDropdown, null);
store.set(objectFilterDropdownCurrentRecordFilter, null);
store.set(objectFilterDropdownAnyFieldSearchIsSelected, false);
@@ -64,6 +72,7 @@ export const useResetFilterDropdown = (componentInstanceId?: string) => {
selectedOperandInDropdown,
objectFilterDropdownFilterIsSelected,
objectFilterDropdownIsSelectingCompositeField,
objectFilterDropdownIsSelectingRelationTargetField,
fieldMetadataItemIdUsedInDropdown,
objectFilterDropdownCurrentRecordFilter,
objectFilterDropdownAnyFieldSearchIsSelected,
@@ -0,0 +1,9 @@
import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const objectFilterDropdownIsSelectingRelationTargetFieldComponentState =
createAtomComponentState<boolean>({
key: 'objectFilterDropdownIsSelectingRelationTargetFieldComponentState',
defaultValue: false,
componentInstanceContext: ObjectFilterDropdownComponentInstanceContext,
});
@@ -1,9 +1,9 @@
import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext';
import { type CompositeFilterableFieldType } from '@/object-record/record-filter/types/CompositeFilterableFieldType';
import { type ObjectFilterDropdownSubMenuFieldType } from '@/object-record/record-filter/types/ObjectFilterDropdownSubMenuFieldType';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const objectFilterDropdownSubMenuFieldTypeComponentState =
createAtomComponentState<CompositeFilterableFieldType | null>({
createAtomComponentState<ObjectFilterDropdownSubMenuFieldType | null>({
key: 'objectFilterDropdownSubMenuFieldTypeComponentState',
defaultValue: null,
componentInstanceContext: ObjectFilterDropdownComponentInstanceContext,
@@ -0,0 +1,9 @@
import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const relationTargetFieldMetadataIdUsedInDropdownComponentState =
createAtomComponentState<string | null>({
key: 'relationTargetFieldMetadataIdUsedInDropdownComponentState',
defaultValue: null,
componentInstanceContext: ObjectFilterDropdownComponentInstanceContext,
});
@@ -1,3 +1,4 @@
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { useRecordCalendarMonthDaysRange } from '@/object-record/record-calendar/month/hooks/useRecordCalendarMonthDaysRange';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
@@ -6,8 +7,10 @@ import { anyFieldFilterValueComponentState } from '@/object-record/record-filter
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { t } from '@lingui/core/macro';
import { type Temporal } from 'temporal-polyfill';
@@ -46,6 +49,10 @@ export const useRecordCalendarQueryDateRangeFilter = (
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const anyFieldFilterValue = useAtomComponentStateValue(
anyFieldFilterValueComponentState,
viewBarInstanceId,
@@ -94,15 +101,21 @@ export const useRecordCalendarQueryDateRangeFilter = (
displayValue: `${lastDayOfLastWeek.toString()}`,
};
const calendarRecordFilters = [
...currentRecordFilters,
dateRangeFilterAfter,
dateRangeFilterBefore,
];
const dateRangeFilter = computeRecordGqlOperationFilter({
filterValueDependencies,
recordFilters: [
...currentRecordFilters,
dateRangeFilterAfter,
dateRangeFilterBefore,
],
recordFilters: calendarRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
fields: objectMetadataItem.fields,
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem.fields,
recordFilters: calendarRecordFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
});
const { recordGqlOperationFilter: anyFieldFilter } =
@@ -156,7 +156,8 @@ export const useGetRecordFilterDisplayValue = () => {
}
const { fieldMetadataItem } = getFieldMetadataItemByIdOrThrow(
recordFilter.fieldMetadataId,
recordFilter.relationTargetFieldMetadataId ??
recordFilter.fieldMetadataId,
);
const fieldMetadataItemOptions = fieldMetadataItem.options;
@@ -0,0 +1,3 @@
import { type CompositeFilterableFieldType } from '@/object-record/record-filter/types/CompositeFilterableFieldType';
export type ObjectFilterDropdownSubMenuFieldType = CompositeFilterableFieldType;
@@ -24,7 +24,7 @@ export type RecordFilter = {
positionInRecordFilterGroup?: number | null;
label: string;
subFieldName?: CompositeFieldSubFieldName | null | undefined;
// RLS-specific: when set, filter compares against current user's field value
relationTargetFieldMetadataId?: string | null;
rlsDynamicValue?: RLSDynamicValue | null;
};
@@ -0,0 +1,41 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { isDefined } from 'twenty-shared/utils';
// Relation-traversal filters reference target fields that live on a
// different object than the one being queried, so they aren't in the
// source object's own field list. The shared GraphQL filter dispatcher
// looks the target up by id in `fieldMetadataItems` and drops the filter
// when it can't find it — this helper merges the resolved target fields
// in from the workspace-wide flat list so callers don't silently lose
// relation-traversal filters.
export const augmentFieldsWithRelationTargets = ({
baseFields,
recordFilters,
allFieldMetadataItems,
}: {
baseFields: FieldMetadataItem[];
recordFilters: Pick<RecordFilter, 'relationTargetFieldMetadataId'>[];
allFieldMetadataItems: FieldMetadataItem[];
}): FieldMetadataItem[] => {
const targetFieldIds = new Set(
recordFilters
.map((filter) => filter.relationTargetFieldMetadataId)
.filter(isDefined),
);
if (targetFieldIds.size === 0) {
return baseFields;
}
const baseFieldIds = new Set(baseFields.map((field) => field.id));
const additionalTargetFields = allFieldMetadataItems.filter(
(field) => targetFieldIds.has(field.id) && !baseFieldIds.has(field.id),
);
if (additionalTargetFields.length === 0) {
return baseFields;
}
return [...baseFields, ...additionalTargetFields];
};
@@ -6,11 +6,13 @@ import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/s
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useFindManyRecordIndexTableParams } from '@/object-record/record-index/hooks/useFindManyRecordIndexTableParams';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useEffect } from 'react';
@@ -52,11 +54,16 @@ export const RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect =
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const computedFilter = computeContextStoreFilters({
contextStoreTargetedRecordsRule,
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
flattenedFieldMetadataItems,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
});
@@ -7,6 +7,7 @@ import { contextStoreFilterGroupsComponentState } from '@/context-store/states/c
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE } from '@/object-record/object-options-dropdown/constants/ExportTableDataDefaultPageSize';
@@ -18,6 +19,7 @@ import { useFindManyRecordIndexTableParams } from '@/object-record/record-index/
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { ViewType } from '@/views/types/ViewType';
import { isDefined } from 'twenty-shared/utils';
@@ -87,6 +89,10 @@ export const useRecordIndexLazyFetchRecords = ({
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const findManyRecordsParams = useFindManyRecordIndexTableParams(
objectMetadataItem.nameSingular,
recordIndexId,
@@ -101,6 +107,7 @@ export const useRecordIndexLazyFetchRecords = ({
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
flattenedFieldMetadataItems,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
});
@@ -1,14 +1,17 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { useCurrentRecordGroupDefinition } from '@/object-record/record-group/hooks/useCurrentRecordGroupDefinition';
import { useRecordGroupFilter } from '@/object-record/record-group/hooks/useRecordGroupFilter';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
combineFilters,
computeRecordGqlOperationFilter,
@@ -47,8 +50,16 @@ export const useFindManyRecordIndexTableParams = (
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const currentFilters = computeRecordGqlOperationFilter({
fields: objectMetadataItem?.fields ?? [],
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem?.fields ?? [],
recordFilters: currentRecordFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
recordFilterGroups: currentRecordFilterGroups,
recordFilters: currentRecordFilters,
filterValueDependencies,
@@ -2,6 +2,7 @@ import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/s
import { useGetFieldMetadataItemByIdOrThrow } from '@/object-metadata/hooks/useGetFieldMetadataItemById';
import { availableFieldMetadataItemsForFilterFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForFilterFamilySelector';
import { availableFieldMetadataItemsForSortFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForSortFamilySelector';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
@@ -30,7 +31,6 @@ import { hasInitializedCurrentRecordFieldsComponentFamilyState } from '@/views/s
import { hasInitializedCurrentRecordFiltersComponentFamilyState } from '@/views/states/hasInitializedCurrentRecordFiltersComponentFamilyState';
import { hasInitializedCurrentRecordSortsComponentFamilyState } from '@/views/states/hasInitializedCurrentRecordSortsComponentFamilyState';
import { type View } from '@/views/types/View';
import { getFilterableFields } from '@/views/utils/getFilterableFields';
import { mapViewFieldToRecordField } from '@/views/utils/mapViewFieldToRecordField';
import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToColumnDefinitions';
import { mapViewFilterGroupsToRecordFilterGroups } from '@/views/utils/mapViewFilterGroupsToRecordFilterGroups';
@@ -128,10 +128,12 @@ export const useLoadRecordIndexStates = () => {
.map(mapViewFieldToRecordField)
.filter(isDefined);
const allFilterableFields = getFilterableFields(objectMetadataItem);
const flattenedFieldMetadataItems = store.get(
flattenedFieldMetadataItemsSelector.atom,
);
const recordFilters = mapViewFiltersToFilters(
view.viewFilters,
allFilterableFields,
flattenedFieldMetadataItems,
);
const recordFilterGroups = mapViewFilterGroupsToRecordFilterGroups(
@@ -140,7 +142,7 @@ export const useLoadRecordIndexStates = () => {
const contextStoreFilters = mapViewFiltersToFilters(
view.viewFilters,
filterableFieldMetadataItems,
flattenedFieldMetadataItems,
);
let recordIndexGroupFieldMetadataItemValue = undefined;
@@ -1,10 +1,12 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { useRelevantRecordsGqlFields } from '@/object-record/record-field/hooks/useRelevantRecordsGqlFields';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { recordGroupDefinitionsComponentSelector } from '@/object-record/record-group/states/selectors/recordGroupDefinitionsComponentSelector';
import { computeRecordGroupOptionsFilter } from '@/object-record/record-group/utils/computeRecordGroupOptionsFilter';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
@@ -12,6 +14,7 @@ import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
combineFilters,
computeRecordGqlOperationFilter,
@@ -36,11 +39,19 @@ export const useRecordIndexGroupCommonQueryVariables = () => {
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const requestFilters = computeRecordGqlOperationFilter({
filterValueDependencies,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
fields: objectMetadataItem.fields,
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem.fields,
recordFilters: currentRecordFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
});
const anyFieldFilterValue = useAtomComponentStateValue(
@@ -1,4 +1,5 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery';
@@ -8,10 +9,12 @@ import { currentRecordFilterGroupsComponentState } from '@/object-record/record-
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { useAggregateGqlFieldsFromRecordIndexGroupAggregates } from '@/object-record/record-index/hooks/useAggregateGqlFieldsFromRecordIndexGroupAggregates';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { buildGroupByFieldObject } from '@/page-layout/widgets/graph/utils/buildGroupByFieldObject';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useQuery } from '@apollo/client/react';
import { useMemo } from 'react';
import { type Nullable } from 'twenty-shared/types';
@@ -46,11 +49,19 @@ export const useRecordIndexGroupsAggregatesGroupBy = ({
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const requestFilters = computeRecordGqlOperationFilter({
filterValueDependencies,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
fields: objectMetadataItem.fields,
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem.fields,
recordFilters: currentRecordFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
});
const { recordAggregateGqlField } =
@@ -1,9 +1,11 @@
import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent';
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { recordIndexHasRecordsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexHasRecordsComponentSelector';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
@@ -13,6 +15,7 @@ import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEvent
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useStore } from 'jotai';
import { useCallback, useMemo } from 'react';
@@ -38,6 +41,10 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
);
@@ -57,7 +64,11 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
objectNameSingular: objectMetadataItem.nameSingular,
variables: {
filter: computeRecordGqlOperationFilter({
fields: objectMetadataItem.fields,
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem.fields,
recordFilters: currentRecordFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
filterValueDependencies,
@@ -71,6 +82,7 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
currentRecordFilterGroups,
filterValueDependencies,
currentRecordSorts,
flattenedFieldMetadataItems,
],
);
@@ -1,3 +1,4 @@
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { transformAggregateRawValueIntoAggregateDisplayValue } from '@/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
@@ -6,6 +7,7 @@ import { currentRecordFilterGroupsComponentState } from '@/object-record/record-
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { useRecordGroupFilter } from '@/object-record/record-group/hooks/useRecordGroupFilter';
import { getRecordAggregateDisplayLabel } from '@/object-record/record-index/utils/getRecordndexAggregateDisplayLabel';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
@@ -45,10 +47,18 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
const dateLocale = useAtomStateValue(dateLocaleState);
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const { filterValueDependencies } = useFilterValueDependencies();
const requestFilters = computeRecordGqlOperationFilter({
fields: objectMetadataItem.fields,
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem.fields,
recordFilters: currentRecordFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
filterValueDependencies,
recordFilterGroups: currentRecordFilterGroups,
recordFilters: currentRecordFilters,
@@ -1,19 +1,26 @@
import { useMemo } from 'react';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { computeRecordGqlOperationFilter } from 'twenty-shared/utils';
export const RecordTableVirtualizedSSESubscribeEffect = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
);
@@ -33,7 +40,11 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => {
objectNameSingular: objectMetadataItem.nameSingular,
variables: {
filter: computeRecordGqlOperationFilter({
fields: objectMetadataItem.fields,
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem.fields,
recordFilters: currentRecordFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
filterValueDependencies,
@@ -47,6 +58,7 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => {
currentRecordFilterGroups,
filterValueDependencies,
currentRecordSorts,
flattenedFieldMetadataItems,
],
);
@@ -4,9 +4,11 @@ import { contextStoreFilterGroupsComponentState } from '@/context-store/states/c
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useIncrementalUpdateManyRecords } from '@/object-record/hooks/useIncrementalUpdateManyRecords';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
type UseUpdateMultipleRecordsActionsProps = {
objectNameSingular: string;
@@ -43,11 +45,16 @@ export const useUpdateMultipleRecordsActions = ({
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const graphqlFilter = computeContextStoreFilters({
contextStoreTargetedRecordsRule,
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
flattenedFieldMetadataItems,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
});
@@ -2,6 +2,7 @@ import { isUndefined } from '@sniptt/guards';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue';
import {
@@ -26,10 +27,7 @@ export const prefillRecord = <T extends ObjectRecord>({
const fieldValue = isUndefined(inputValue)
? generateEmptyFieldValue({ fieldMetadataItem })
: inputValue;
if (
fieldMetadataItem.type === FieldMetadataType.RELATION &&
fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE
) {
if (isManyToOneRelationField(fieldMetadataItem)) {
const joinColumnName = computeRelationGqlFieldJoinColumnName({
name: fieldMetadataItem.name,
});
@@ -1,4 +1,5 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isSystemSearchVectorField } from '@/object-record/utils/isSystemSearchVectorField';
@@ -63,8 +64,7 @@ export const sanitizeRecordInput = ({
if (
isDefined(fieldMetadataItem) &&
fieldMetadataItem.type === FieldMetadataType.RELATION &&
fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE &&
isManyToOneRelationField(fieldMetadataItem) &&
!isDefined(recordInput[fieldMetadataItem.name]?.connect?.where)
) {
return undefined;
@@ -1,5 +1,8 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
computeRecordGqlOperationFilter,
isDefined,
@@ -38,10 +41,20 @@ export const useGraphWidgetQueryCommon = ({
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const widgetRecordFilters = configuration.filter?.recordFilters ?? [];
const gqlOperationFilter = computeRecordGqlOperationFilter({
fields: objectMetadataItem.fields,
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem.fields,
recordFilters: widgetRecordFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
filterValueDependencies,
recordFilters: configuration.filter?.recordFilters ?? [],
recordFilters: widgetRecordFilters,
recordFilterGroups: configuration.filter?.recordFilterGroups ?? [],
});
@@ -7,7 +7,8 @@ import { CoreObjectNameSingular, FieldMetadataType } from 'twenty-shared/types';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { AdvancedFilterFieldSelectSearchInput } from '@/object-record/advanced-filter/components/AdvancedFilterFieldSelectSearchInput';
import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterFieldSelectDropdown';
import { useSelectFieldUsedInAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown';
import { useApplyAdvancedFilterSourceField } from '@/object-record/advanced-filter/hooks/useApplyAdvancedFilterSourceField';
import { usePushFocusForLeafFieldValuePicker } from '@/object-record/advanced-filter/hooks/usePushFocusForLeafFieldValuePicker';
import { AdvancedFilterContext } from '@/object-record/advanced-filter/states/context/AdvancedFilterContext';
import { ObjectFilterDropdownFilterSelectMenuItem } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectMenuItem';
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
@@ -73,8 +74,8 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectF
advancedFilterFieldSelectDropdownId,
);
const { selectFieldUsedInAdvancedFilterDropdown } =
useSelectFieldUsedInAdvancedFilterDropdown();
const { applyAdvancedFilterSourceField } =
useApplyAdvancedFilterSourceField();
const [, setObjectFilterDropdownSubMenuFieldType] = useAtomComponentState(
objectFilterDropdownSubMenuFieldTypeComponentState,
@@ -89,6 +90,9 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectF
fieldMetadataItemIdUsedInDropdownComponentState,
);
const { pushFocusForLeafFieldValuePicker } =
usePushFocusForLeafFieldValuePicker();
const handleFieldSelect = (
selectedFieldMetadataItem: FieldMetadataItem,
) => {
@@ -102,13 +106,17 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectF
setObjectFilterDropdownSubMenuFieldType(filterType);
setFieldMetadataItemIdUsedInDropdown(selectedFieldMetadataItem.id);
setObjectFilterDropdownIsSelectingCompositeField(true);
} else {
selectFieldUsedInAdvancedFilterDropdown({
fieldMetadataItemId: selectedFieldMetadataItem.id,
recordFilterId,
});
closeAdvancedFilterFieldSelectDropdown();
return;
}
applyAdvancedFilterSourceField({
sourceFieldMetadataItem: selectedFieldMetadataItem,
recordFilterId,
});
pushFocusForLeafFieldValuePicker(selectedFieldMetadataItem);
closeAdvancedFilterFieldSelectDropdown();
};
const selectableItemIdArray = filteredFieldMetadataItems.map(
@@ -10,7 +10,7 @@ import { MenuItem } from 'twenty-ui/navigation';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterFieldSelectDropdown';
import { useSelectFieldUsedInAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown';
import { useApplyAdvancedFilterCompositeSubField } from '@/object-record/advanced-filter/hooks/useApplyAdvancedFilterCompositeSubField';
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState';
import { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState';
@@ -61,8 +61,8 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectS
const { closeAdvancedFilterFieldSelectDropdown } =
useAdvancedFilterFieldSelectDropdown(recordFilterId);
const { selectFieldUsedInAdvancedFilterDropdown } =
useSelectFieldUsedInAdvancedFilterDropdown();
const { applyAdvancedFilterCompositeSubField } =
useApplyAdvancedFilterCompositeSubField();
const handleSelectFilter = (
selectedFieldMetadataItem: FieldMetadataItem | null | undefined,
@@ -72,10 +72,10 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectS
return;
}
selectFieldUsedInAdvancedFilterDropdown({
fieldMetadataItemId: selectedFieldMetadataItem.id,
applyAdvancedFilterCompositeSubField({
sourceFieldMetadataItem: selectedFieldMetadataItem,
subFieldName: subFieldName ?? null,
recordFilterId,
subFieldName,
});
closeAdvancedFilterFieldSelectDropdown();
@@ -71,10 +71,7 @@ export const RecordTableSettingsFilters = ({
isWorkflowFindRecords={false}
onUpdate={handleFilterUpdate}
/>
<RecordTableSettingsFiltersInitializeStateEffect
view={view}
objectMetadataItem={objectMetadataItem}
/>
<RecordTableSettingsFiltersInitializeStateEffect view={view} />
</RecordFiltersComponentInstanceContext.Provider>
</RecordFilterGroupsComponentInstanceContext.Provider>
</StyledFilterSettingsContainer>
@@ -1,11 +1,11 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useSetAdvancedFilterDropdownStates } from '@/object-record/advanced-filter/hooks/useSetAdvancedFilterDropdownAllRowsStates';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { type View } from '@/views/types/View';
import { getFilterableFields } from '@/views/utils/getFilterableFields';
import { mapViewFilterGroupsToRecordFilterGroups } from '@/views/utils/mapViewFilterGroupsToRecordFilterGroups';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
import { useEffect, useState } from 'react';
@@ -13,12 +13,10 @@ import { isDefined } from 'twenty-shared/utils';
type RecordTableSettingsFiltersInitializeStateEffectProps = {
view: View;
objectMetadataItem: EnrichedObjectMetadataItem;
};
export const RecordTableSettingsFiltersInitializeStateEffect = ({
view,
objectMetadataItem,
}: RecordTableSettingsFiltersInitializeStateEffectProps) => {
const setCurrentRecordFilters = useSetAtomComponentState(
currentRecordFiltersComponentState,
@@ -39,6 +37,10 @@ export const RecordTableSettingsFiltersInitializeStateEffect = ({
currentRecordFilterGroupsComponentState,
);
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const [hasInitializedFilters, setHasInitializedFilters] = useState(false);
const stateAlreadyHasFilters =
@@ -54,10 +56,9 @@ export const RecordTableSettingsFiltersInitializeStateEffect = ({
return;
}
const filterableFields = getFilterableFields(objectMetadataItem);
const recordFilters = mapViewFiltersToFilters(
view.viewFilters,
filterableFields,
flattenedFieldMetadataItems,
);
setCurrentRecordFilters(recordFilters);
@@ -71,7 +72,7 @@ export const RecordTableSettingsFiltersInitializeStateEffect = ({
setHasInitializedFilters(true);
}, [
view,
objectMetadataItem,
flattenedFieldMetadataItems,
hasInitializedFilters,
stateAlreadyHasFilters,
setCurrentRecordFilters,
@@ -1,12 +1,10 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from '~/generated-metadata/graphql';
export const hasNestedFields = (fieldMetadata: FieldMetadataItem) => {
return (
(fieldMetadata.type === FieldMetadataType.RELATION &&
fieldMetadata.relation?.type === RelationType.MANY_TO_ONE) ||
isManyToOneRelationField(fieldMetadata) ||
isCompositeFieldType(fieldMetadata.type)
);
};
@@ -9,6 +9,7 @@ export const VIEW_FILTER_FRAGMENT = gql`
viewFilterGroupId
positionInViewFilterGroup
subFieldName
relationTargetFieldMetadataId
viewId
createdAt
updatedAt
@@ -125,6 +125,7 @@ describe('useApplyCurrentViewFiltersToCurrentRecordFilters', () => {
label: mockFieldMetadataItem.label,
type: getFilterTypeFromFieldType(mockFieldMetadataItem.type),
subFieldName: null,
relationTargetFieldMetadataId: null,
} satisfies RecordFilter,
]);
});
@@ -1,11 +1,14 @@
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useGetViewGroupsFilters } from '@/views/hooks/useGetViewGroupsFilters';
import {
computeRecordGqlOperationFilter,
@@ -25,13 +28,21 @@ export const useGetRecordIndexTotalCount = () => {
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const recordGroupsVisibilityFilter = useGetViewGroupsFilters();
const filter = computeRecordGqlOperationFilter({
filterValueDependencies,
recordFilters: [...currentRecordFilters, ...recordGroupsVisibilityFilter],
recordFilterGroups: currentRecordFilterGroups,
fields: objectMetadataItem.fields,
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem.fields,
recordFilters: [...currentRecordFilters, ...recordGroupsVisibilityFilter],
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
});
const anyFieldFilterValue = useAtomComponentStateValue(
@@ -1,18 +1,18 @@
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type ViewFilter as GqlViewFilter } from '~/generated-metadata/graphql';
import { type ViewFilter } from '@/views/types/ViewFilter';
import { getFilterableFields } from '@/views/utils/getFilterableFields';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
export const useMapViewFiltersToFilters = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const mapViewFiltersToRecordFilters = (
viewFilters: ViewFilter[] | GqlViewFilter[],
) => {
const filterableFieldMetadataItems =
getFilterableFields(objectMetadataItem);
return mapViewFiltersToFilters(viewFilters, filterableFieldMetadataItems);
return mapViewFiltersToFilters(viewFilters, flattenedFieldMetadataItems);
};
return { mapViewFiltersToRecordFilters };
@@ -1,9 +1,11 @@
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreRecordShowParentViewComponentState } from '@/context-store/states/contextStoreRecordShowParentViewComponentState';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { getQueryVariablesFromFiltersAndSorts } from '@/views/utils/getQueryVariablesFromFiltersAndSorts';
export const useQueryVariablesFromParentView = ({
@@ -13,6 +15,10 @@ export const useQueryVariablesFromParentView = ({
}) => {
const { objectMetadataItems } = useObjectMetadataItems();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const contextStoreRecordShowParentView = useAtomComponentStateValue(
contextStoreRecordShowParentViewComponentState,
MAIN_CONTEXT_STORE_INSTANCE_ID,
@@ -27,6 +33,7 @@ export const useQueryVariablesFromParentView = ({
recordSorts: contextStoreRecordShowParentView?.parentViewSorts ?? [],
objectMetadataItem,
objectMetadataItems,
flattenedFieldMetadataItems,
filterValueDependencies,
});
@@ -144,6 +144,8 @@ export const useSaveRecordFiltersAndGroupFiltersToViewFiltersAndGroupFilters =
viewFilterGroupId: viewFilter.viewFilterGroupId,
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
subFieldName: viewFilter.subFieldName ?? null,
relationTargetFieldMetadataId:
viewFilter.relationTargetFieldMetadataId ?? null,
},
}),
);
@@ -158,6 +160,8 @@ export const useSaveRecordFiltersAndGroupFiltersToViewFiltersAndGroupFilters =
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
viewFilterGroupId: viewFilter.viewFilterGroupId,
subFieldName: viewFilter.subFieldName ?? null,
relationTargetFieldMetadataId:
viewFilter.relationTargetFieldMetadataId ?? null,
},
},
}),
@@ -1,5 +1,6 @@
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
import { relationTargetFieldMetadataIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/relationTargetFieldMetadataIdUsedInDropdownComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
import { useFilterableFieldMetadataItemsInRecordIndexContext } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItemsInRecordIndexContext';
@@ -59,6 +60,15 @@ export const useSetEditableFilterChipDropdownStates = () => {
}),
recordFilter.subFieldName,
);
store.set(
relationTargetFieldMetadataIdUsedInDropdownComponentState.atomFamily({
instanceId: getEditableChipObjectFilterDropdownComponentInstanceId({
recordFilterId: recordFilter.id,
}),
}),
recordFilter.relationTargetFieldMetadataId ?? null,
);
},
[store, filterableFieldMetadataItems],
);
@@ -12,4 +12,5 @@ export type ViewFilter = {
viewFilterGroupId?: string | null;
positionInViewFilterGroup?: number | null;
subFieldName?: string | null;
relationTargetFieldMetadataId?: string | null;
};
@@ -43,6 +43,8 @@ describe('mapViewFiltersToFilters', () => {
type: FieldMetadataType.FULL_NAME,
positionInRecordFilterGroup: undefined,
recordFilterGroupId: undefined,
subFieldName: undefined,
relationTargetFieldMetadataId: null,
},
];
expect(
@@ -12,6 +12,7 @@ export const areViewFiltersEqual = (
'value',
'operand',
'subFieldName',
'relationTargetFieldMetadataId',
];
return propertiesToCompare.every((property) =>
@@ -1,7 +1,9 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { type RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { type RecordFilterValueDependencies } from 'twenty-shared/types';
import { computeRecordGqlOperationFilter } from 'twenty-shared/utils';
@@ -12,6 +14,7 @@ export const getQueryVariablesFromFiltersAndSorts = ({
recordSorts,
objectMetadataItem,
objectMetadataItems = [],
flattenedFieldMetadataItems,
filterValueDependencies,
}: {
recordFilterGroups: RecordFilterGroup[];
@@ -19,10 +22,15 @@ export const getQueryVariablesFromFiltersAndSorts = ({
recordSorts: RecordSort[];
objectMetadataItem: EnrichedObjectMetadataItem;
objectMetadataItems?: EnrichedObjectMetadataItem[];
flattenedFieldMetadataItems: FieldMetadataItem[];
filterValueDependencies: RecordFilterValueDependencies;
}) => {
const filter = computeRecordGqlOperationFilter({
fields: objectMetadataItem?.fields ?? [],
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem?.fields ?? [],
recordFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
filterValueDependencies,
recordFilterGroups,
recordFilters,
@@ -13,5 +13,7 @@ export const mapRecordFilterToViewFilter = (
positionInViewFilterGroup: recordFilter.positionInRecordFilterGroup,
viewFilterGroupId: recordFilter.recordFilterGroupId,
subFieldName: recordFilter.subFieldName,
relationTargetFieldMetadataId:
recordFilter.relationTargetFieldMetadataId ?? null,
};
};
@@ -14,27 +14,50 @@ import { type ViewFilter } from '@/views/types/ViewFilter';
export const mapViewFiltersToFilters = (
viewFilters: ViewFilter[] | GqlViewFilter[],
availableFieldMetadataItems: FieldMetadataItem[],
fieldMetadataItems: FieldMetadataItem[],
): RecordFilter[] => {
return viewFilters
.map((viewFilter) => {
const availableFieldMetadataItem = availableFieldMetadataItems.find(
const sourceFieldMetadataItem = fieldMetadataItems.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === viewFilter.fieldMetadataId,
);
if (!isDefined(availableFieldMetadataItem)) {
// Todo: we we don't throw an error yet as we have race condition on view change
if (!isDefined(sourceFieldMetadataItem)) {
// Todo: we don't throw an error yet as we have race condition on view change
return undefined;
}
const filterType = getFilterTypeFromFieldType(
availableFieldMetadataItem.type,
);
const relationTargetFieldMetadataItem = isDefined(
viewFilter.relationTargetFieldMetadataId,
)
? fieldMetadataItems.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === viewFilter.relationTargetFieldMetadataId,
)
: undefined;
const label = isSystemSearchVectorField(availableFieldMetadataItem.name)
// A relation-traversal filter is meaningful only if both ends
// resolve — drop it otherwise (same race-condition handling as the
// source field above) instead of rendering a chip whose filterType
// and label fall back to the relation source, since the dispatcher
// would silently drop the GraphQL filter anyway.
if (
isDefined(viewFilter.relationTargetFieldMetadataId) &&
!isDefined(relationTargetFieldMetadataItem)
) {
return undefined;
}
const filterType = isDefined(relationTargetFieldMetadataItem)
? getFilterTypeFromFieldType(relationTargetFieldMetadataItem.type)
: getFilterTypeFromFieldType(sourceFieldMetadataItem.type);
const label = isSystemSearchVectorField(sourceFieldMetadataItem.name)
? 'Search'
: availableFieldMetadataItem.label;
: isDefined(relationTargetFieldMetadataItem)
? `${sourceFieldMetadataItem.label}${relationTargetFieldMetadataItem.label}`
: sourceFieldMetadataItem.label;
const operand = viewFilter.operand;
@@ -54,6 +77,8 @@ export const mapViewFiltersToFilters = (
label,
type: filterType,
subFieldName: viewFilter.subFieldName as CompositeFieldSubFieldName,
relationTargetFieldMetadataId:
viewFilter.relationTargetFieldMetadataId ?? null,
} satisfies RecordFilter;
})
.filter(isDefined);
@@ -0,0 +1,33 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.6.0', 1798000005000)
export class AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."viewFilter" ADD "relationTargetFieldMetadataId" uuid`,
);
await queryRunner.query(
`CREATE INDEX "IDX_VIEW_FILTER_RELATION_TARGET_FIELD_METADATA_ID" ON "core"."viewFilter" ("relationTargetFieldMetadataId") WHERE "relationTargetFieldMetadataId" IS NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."viewFilter" ADD CONSTRAINT "FK_dbe259395cbd9a54c1c17d12b0b" FOREIGN KEY ("relationTargetFieldMetadataId") REFERENCES "core"."fieldMetadata"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."viewFilter" DROP CONSTRAINT "FK_dbe259395cbd9a54c1c17d12b0b"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_VIEW_FILTER_RELATION_TARGET_FIELD_METADATA_ID"`,
);
await queryRunner.query(
`ALTER TABLE "core"."viewFilter" DROP COLUMN "relationTargetFieldMetadataId"`,
);
}
}
@@ -42,6 +42,7 @@ import { EncryptSensitiveConfigStorageSlowInstanceCommand } from 'src/database/c
import { EncryptTotpSecretsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets';
import { AddSubFieldNameToViewSortFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778502963794-add-sub-field-name-to-view-sort';
import { DropPostgresCredentialsTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1798500000000-drop-postgres-credentials-table';
import { AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1798000005000-add-relation-target-field-metadata-id-to-view-filter';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -86,4 +87,5 @@ export const INSTANCE_COMMANDS = [
EncryptTotpSecretsSlowInstanceCommand,
AddSubFieldNameToViewSortFastInstanceCommand,
DropPostgresCredentialsTableFastInstanceCommand,
AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand,
];
@@ -231,6 +231,8 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
recordFilterGroupId: viewFilter.viewFilterGroupId,
positionInRecordFilterGroup: viewFilter.positionInViewFilterGroup,
subFieldName: viewFilter.subFieldName as CompositeFieldSubFieldName,
relationTargetFieldMetadataId:
viewFilter.relationTargetFieldMetadataId ?? null,
};
});
@@ -254,6 +256,33 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
options: field.options as PartialFieldMetadataItemOption[],
}));
// Relation-traversal filters reference target fields on related objects
// that aren't in the source object's field list above. Resolve and add
// them so the shared dispatcher can look them up by id.
const relationTargetFieldsFromFilters = recordFilters
.map((filter) => filter.relationTargetFieldMetadataId)
.filter(isDefined)
.filter((id) => !fields.some((field) => field.id === id))
.map((id) => {
const field = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: id,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!field) return null;
return {
id: field.id,
name: field.name,
type: field.type,
label: field.label,
options: field.options as PartialFieldMetadataItemOption[],
};
})
.filter(isDefined);
fields.push(...relationTargetFieldsFromFilters);
const filtersFromView = computeRecordGqlOperationFilter({
recordFilters,
recordFilterGroups: recordFilterGroups,
@@ -24,6 +24,7 @@ export const fromViewFilterManifestToUniversalFlatViewFilter = ({
operand: viewFilterManifest.operand,
value: viewFilterManifest.value,
subFieldName: viewFilterManifest.subFieldName ?? null,
relationTargetFieldMetadataUniversalIdentifier: null,
positionInViewFilterGroup:
viewFilterManifest.positionInViewFilterGroup ?? null,
createdAt: now,
@@ -356,6 +356,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"viewFilterGroupUniversalIdentifier",
"positionInViewFilterGroup",
"subFieldName",
"relationTargetFieldMetadataUniversalIdentifier",
],
"propertiesToStringify": [
"value",
@@ -669,6 +669,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: false,
universalProperty: undefined,
},
relationTargetFieldMetadataId: {
toCompare: true,
toStringify: false,
universalProperty: 'relationTargetFieldMetadataUniversalIdentifier',
},
createdAt: {
toCompare: false,
toStringify: false,
@@ -132,6 +132,9 @@ export const ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY = {
view: {
foreignKey: 'viewId',
},
relationTargetFieldMetadata: {
foreignKey: 'relationTargetFieldMetadataId',
},
viewFilterGroup: {
foreignKey: 'viewFilterGroupId',
},
@@ -241,6 +241,13 @@ export const ALL_MANY_TO_ONE_METADATA_RELATIONS = {
isNullable: false,
universalForeignKey: 'viewUniversalIdentifier',
},
relationTargetFieldMetadata: {
metadataName: 'fieldMetadata',
foreignKey: 'relationTargetFieldMetadataId',
inverseOneToManyProperty: null,
isNullable: true,
universalForeignKey: 'relationTargetFieldMetadataUniversalIdentifier',
},
viewFilterGroup: {
metadataName: 'viewFilterGroup',
foreignKey: 'viewFilterGroupId',
@@ -4,10 +4,10 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with
[
"rowLevelPermissionPredicate",
"navigationMenuItem",
"viewFilter",
"commandMenuItem",
"fieldPermission",
"viewField",
"viewFilter",
"objectPermission",
"pageLayoutWidget",
"viewSort",
@@ -7,4 +7,5 @@ export const FLAT_VIEW_FILTER_EDITABLE_PROPERTIES = [
'viewFilterGroupId',
'positionInViewFilterGroup',
'subFieldName',
'relationTargetFieldMetadataId',
] as const satisfies MetadataEntityPropertyName<'viewFilter'>[];
@@ -31,6 +31,7 @@ export const fromCreateViewFilterInputToFlatViewFilterToCreate = ({
'viewFilterGroupId',
'operand',
'subFieldName',
'relationTargetFieldMetadataId',
],
);
@@ -41,12 +42,15 @@ export const fromCreateViewFilterInputToFlatViewFilterToCreate = ({
fieldMetadataUniversalIdentifier,
viewUniversalIdentifier,
viewFilterGroupUniversalIdentifier,
relationTargetFieldMetadataUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilter',
foreignKeyValues: {
fieldMetadataId,
viewId,
viewFilterGroupId: createViewFilterInput.viewFilterGroupId,
relationTargetFieldMetadataId:
createViewFilterInput.relationTargetFieldMetadataId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
@@ -69,6 +73,7 @@ export const fromCreateViewFilterInputToFlatViewFilterToCreate = ({
positionInViewFilterGroup:
createViewFilterInput.positionInViewFilterGroup ?? null,
subFieldName: createViewFilterInput.subFieldName ?? null,
relationTargetFieldMetadataUniversalIdentifier,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
};
};
@@ -87,5 +87,22 @@ export const fromUpdateViewFilterInputToFlatViewFilterToUpdateOrThrow = ({
viewFilterGroupUniversalIdentifier;
}
if (
updatedEditableFieldProperties.relationTargetFieldMetadataId !== undefined
) {
const { relationTargetFieldMetadataUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilter',
foreignKeyValues: {
relationTargetFieldMetadataId:
flatViewFilterToUpdate.relationTargetFieldMetadataId,
},
flatEntityMaps: { flatFieldMetadataMaps },
});
flatViewFilterToUpdate.relationTargetFieldMetadataUniversalIdentifier =
relationTargetFieldMetadataUniversalIdentifier;
}
return flatViewFilterToUpdate;
};
@@ -69,6 +69,22 @@ export const fromViewFilterEntityToFlatViewFilter = ({
);
}
let relationTargetFieldMetadataUniversalIdentifier: string | null = null;
if (isDefined(viewFilterEntity.relationTargetFieldMetadataId)) {
relationTargetFieldMetadataUniversalIdentifier =
fieldMetadataIdToUniversalIdentifierMap.get(
viewFilterEntity.relationTargetFieldMetadataId,
) ?? null;
if (!isDefined(relationTargetFieldMetadataUniversalIdentifier)) {
throw new FlatEntityMapsException(
`FieldMetadata with id ${viewFilterEntity.relationTargetFieldMetadataId} not found for viewFilter ${viewFilterEntity.id} (relation target)`,
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
}
return {
...viewFilterEntityWithoutRelations,
createdAt: viewFilterEntity.createdAt.toISOString(),
@@ -77,6 +93,7 @@ export const fromViewFilterEntityToFlatViewFilter = ({
universalIdentifier: viewFilterEntityWithoutRelations.universalIdentifier,
applicationUniversalIdentifier,
fieldMetadataUniversalIdentifier,
relationTargetFieldMetadataUniversalIdentifier,
viewFilterGroupUniversalIdentifier,
viewUniversalIdentifier,
};
@@ -49,6 +49,11 @@ export class CreateViewFilterInput {
@Field({ nullable: true })
subFieldName?: string;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
relationTargetFieldMetadataId?: string;
@IsUUID()
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
@@ -46,6 +46,11 @@ class UpdateViewFilterInputUpdates {
@IsString()
@Field({ nullable: true })
subFieldName?: string;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
relationTargetFieldMetadataId?: string;
}
@InputType()
@@ -34,6 +34,9 @@ export class ViewFilterDTO {
@Field(() => String, { nullable: true })
subFieldName?: string | null;
@Field(() => UUIDScalarType, { nullable: true })
relationTargetFieldMetadataId?: string | null;
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
@@ -23,6 +23,11 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
@Index('IDX_VIEW_FILTER_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId'])
@Index('IDX_VIEW_FILTER_VIEW_ID', ['viewId'])
@Index('IDX_VIEW_FILTER_FIELD_METADATA_ID', ['fieldMetadataId'])
@Index(
'IDX_VIEW_FILTER_RELATION_TARGET_FIELD_METADATA_ID',
['relationTargetFieldMetadataId'],
{ where: '"relationTargetFieldMetadataId" IS NOT NULL' },
)
export class ViewFilterEntity
extends SyncableEntity
implements Required<ViewFilterEntity>
@@ -59,6 +64,16 @@ export class ViewFilterEntity
@Column({ nullable: true, type: 'text', default: null })
subFieldName: string | null;
@Column({ nullable: true, type: 'uuid', default: null })
relationTargetFieldMetadataId: string | null;
@ManyToOne(() => FieldMetadataEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'relationTargetFieldMetadataId' })
relationTargetFieldMetadata: Relation<FieldMetadataEntity> | null;
@Column({ nullable: false, type: 'uuid' })
viewId: string;
@@ -48,4 +48,9 @@ export class UpsertViewWidgetViewFilterInput {
@IsString()
@Field({ nullable: true })
subFieldName?: string;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
relationTargetFieldMetadataId?: string;
}
@@ -267,5 +267,83 @@ describe('ViewQueryParamsService', () => {
// Filter should be effectively empty because the field was deleted
expect(result.filter).toEqual({ and: [] });
});
it('should resolve relation-traversal filters against the target field', async () => {
const relationFieldId = 'relation-field-id';
const targetFieldId = 'target-field-id';
const mockFilterGroupId = 'filter-group-id';
const flatFieldMetadataMapsWithRelation = {
byUniversalIdentifier: {
'relation-universal-id': {
id: relationFieldId,
name: 'company',
type: FieldMetadataType.RELATION,
label: 'Company',
options: null,
universalIdentifier: 'relation-universal-id',
},
'target-universal-id': {
id: targetFieldId,
name: 'name',
type: FieldMetadataType.TEXT,
label: 'Name',
options: null,
universalIdentifier: 'target-universal-id',
},
},
universalIdentifierById: {
[relationFieldId]: 'relation-universal-id',
[targetFieldId]: 'target-universal-id',
},
universalIdentifiersByApplicationId: {},
};
const mockView = {
id: mockViewId,
name: 'People at Acme',
objectMetadataId: mockObjectMetadataId,
type: ViewType.TABLE,
visibility: ViewVisibility.WORKSPACE,
viewFilters: [
{
id: 'filter-id',
fieldMetadataId: relationFieldId,
operand: ViewFilterOperand.CONTAINS,
value: 'Acme',
viewFilterGroupId: mockFilterGroupId,
subFieldName: null,
relationTargetFieldMetadataId: targetFieldId,
},
],
viewFilterGroups: [
{
id: mockFilterGroupId,
parentViewFilterGroupId: null,
logicalOperator: ViewFilterGroupLogicalOperator.AND,
},
],
viewSorts: [],
};
viewService.findByIdWithRelations.mockResolvedValue(mockView as any);
flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue(
{
flatObjectMetadataMaps: mockFlatObjectMetadataMaps,
flatFieldMetadataMaps: flatFieldMetadataMapsWithRelation,
} as any,
);
const result = await viewQueryParamsService.resolveViewToQueryParams(
mockViewId,
mockWorkspaceId,
);
// Filter is nested under the relation field name, not flattened
// against the FK column.
expect(result.filter).toEqual({
and: [{ company: { name: { ilike: '%Acme%' } } }],
});
});
});
});
@@ -90,6 +90,8 @@ export class ViewQueryParamsService {
recordFilterGroupId: viewFilter.viewFilterGroupId,
operand: viewFilter.operand,
subFieldName: viewFilter.subFieldName,
relationTargetFieldMetadataId:
viewFilter.relationTargetFieldMetadataId ?? null,
} as RecordFilter;
})
.filter(isDefined);
@@ -105,10 +107,16 @@ export class ViewQueryParamsService {
: RecordFilterGroupLogicalOperator.AND,
}));
const fields = recordFilters
.map((filter) => {
const filterFieldMetadataIds = recordFilters.flatMap((filter) =>
isDefined(filter.relationTargetFieldMetadataId)
? [filter.fieldMetadataId, filter.relationTargetFieldMetadataId]
: [filter.fieldMetadataId],
);
const fields = filterFieldMetadataIds
.map((fieldMetadataId) => {
const field = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: filter.fieldMetadataId,
flatEntityId: fieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
@@ -675,12 +675,15 @@ export class ViewWidgetUpsertService {
fieldMetadataUniversalIdentifier,
viewUniversalIdentifier,
viewFilterGroupUniversalIdentifier,
relationTargetFieldMetadataUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilter',
foreignKeyValues: {
fieldMetadataId: inputFilter.fieldMetadataId,
viewId,
viewFilterGroupId: inputFilter.viewFilterGroupId,
relationTargetFieldMetadataId:
inputFilter.relationTargetFieldMetadataId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
@@ -706,6 +709,9 @@ export class ViewWidgetUpsertService {
positionInViewFilterGroup:
inputFilter.positionInViewFilterGroup ?? null,
subFieldName: inputFilter.subFieldName ?? null,
relationTargetFieldMetadataId:
inputFilter.relationTargetFieldMetadataId ?? null,
relationTargetFieldMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
deletedAt: null,
@@ -719,17 +725,22 @@ export class ViewWidgetUpsertService {
existingFilter.viewFilterGroupId !== inputFilter.viewFilterGroupId ||
existingFilter.positionInViewFilterGroup !==
inputFilter.positionInViewFilterGroup ||
existingFilter.subFieldName !== inputFilter.subFieldName;
existingFilter.subFieldName !== inputFilter.subFieldName ||
existingFilter.relationTargetFieldMetadataId !==
(inputFilter.relationTargetFieldMetadataId ?? null);
if (hasChanged) {
const {
fieldMetadataUniversalIdentifier,
viewFilterGroupUniversalIdentifier,
relationTargetFieldMetadataUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilter',
foreignKeyValues: {
fieldMetadataId: inputFilter.fieldMetadataId,
viewFilterGroupId: inputFilter.viewFilterGroupId,
relationTargetFieldMetadataId:
inputFilter.relationTargetFieldMetadataId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
@@ -751,6 +762,9 @@ export class ViewWidgetUpsertService {
existingFilter.positionInViewFilterGroup,
subFieldName:
inputFilter.subFieldName ?? existingFilter.subFieldName,
relationTargetFieldMetadataId:
inputFilter.relationTargetFieldMetadataId ?? null,
relationTargetFieldMetadataUniversalIdentifier,
updatedAt: now,
});
}
@@ -96,6 +96,8 @@ export const createStandardViewFilterFlatMetadata = <
operand,
value,
subFieldName,
relationTargetFieldMetadataId: null,
relationTargetFieldMetadataUniversalIdentifier: null,
positionInViewFilterGroup,
createdAt: now,
updatedAt: now,
@@ -26,12 +26,16 @@ export class CreateViewFilterActionHandlerService extends WorkspaceMigrationRunn
flatApplication,
workspaceId,
}: WorkspaceMigrationActionRunnerArgs<UniversalCreateViewFilterAction>): Promise<FlatCreateViewFilterAction> {
const { fieldMetadataId, viewId, viewFilterGroupId } =
resolveUniversalRelationIdentifiersToIds({
flatEntityMaps: allFlatEntityMaps,
metadataName: action.metadataName,
universalForeignKeyValues: action.flatEntity,
});
const {
fieldMetadataId,
viewId,
viewFilterGroupId,
relationTargetFieldMetadataId,
} = resolveUniversalRelationIdentifiersToIds({
flatEntityMaps: allFlatEntityMaps,
metadataName: action.metadataName,
universalForeignKeyValues: action.flatEntity,
});
const emptyUniversalForeignKeyAggregators =
getUniversalFlatEntityEmptyForeignKeyAggregators({
@@ -45,6 +49,7 @@ export class CreateViewFilterActionHandlerService extends WorkspaceMigrationRunn
fieldMetadataId,
viewId,
viewFilterGroupId,
relationTargetFieldMetadataId,
id: action.id ?? v4(),
applicationId: flatApplication.id,
workspaceId,
@@ -69,7 +69,21 @@ export class FindRecordsWorkflowAction implements WorkflowAction {
workspaceId,
);
const fields = flatObjectMetadata.fieldIds
// Relation-traversal filters reference target fields on related
// objects, which aren't in flatObjectMetadata.fieldIds. Collect the
// referenced target ids from the record filters so the shared
// dispatcher can resolve them and avoid silently dropping the filter.
const relationTargetFieldIds = (
workflowActionInput.filter?.recordFilters ?? []
)
.map((filter) => filter.relationTargetFieldMetadataId)
.filter(isDefined);
const fieldIds = Array.from(
new Set([...flatObjectMetadata.fieldIds, ...relationTargetFieldIds]),
);
const fields = fieldIds
.map((fieldId) => {
const field = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: fieldId,
@@ -0,0 +1,229 @@
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { createOneViewFilter } from 'test/integration/metadata/suites/view-filter/utils/create-one-view-filter.util';
import { destroyOneViewFilter } from 'test/integration/metadata/suites/view-filter/utils/destroy-one-view-filter.util';
import { updateOneViewFilter } from 'test/integration/metadata/suites/view-filter/utils/update-one-view-filter.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 { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import { ViewFilterOperand, ViewType } from 'twenty-shared/types';
// Round-trip of relation-traversal view filters (the one-hop
// `relationTargetFieldMetadataId` carried alongside the source relation
// field id) through the metadata create + update mutations.
const RELATION_TRAVERSAL_GQL_FIELDS = `
id
fieldMetadataId
operand
value
viewId
subFieldName
relationTargetFieldMetadataId
createdAt
updatedAt
deletedAt
`;
describe('View Filter relation-traversal round-trip should succeed', () => {
let createdViewId: string;
let createdViewFilterId: string;
let personCompanyRelationFieldId: string;
let companyNameFieldId: string;
let companyDomainNameFieldId: string;
beforeAll(async () => {
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: {},
paging: { first: 1000 },
},
gqlFields: `
id
nameSingular
fieldsList {
id
name
type
}
`,
});
jestExpectToBeDefined(objects);
const personObject = objects.find(
(object: { nameSingular: string }) => object.nameSingular === 'person',
);
jestExpectToBeDefined(personObject);
const companyObject = objects.find(
(object: { nameSingular: string }) => object.nameSingular === 'company',
);
jestExpectToBeDefined(companyObject);
const personCompanyRelationField = personObject.fieldsList?.find(
(field: { name: string; type: string }) =>
field.name === 'company' && field.type === 'RELATION',
);
jestExpectToBeDefined(personCompanyRelationField);
personCompanyRelationFieldId = personCompanyRelationField.id;
const companyNameField = companyObject.fieldsList?.find(
(field: { name: string }) => field.name === 'name',
);
jestExpectToBeDefined(companyNameField);
companyNameFieldId = companyNameField.id;
const companyDomainNameField = companyObject.fieldsList?.find(
(field: { name: string }) => field.name === 'domainName',
);
jestExpectToBeDefined(companyDomainNameField);
companyDomainNameFieldId = companyDomainNameField.id;
const { data: viewData } = await createOneView({
expectToFail: false,
input: {
name: 'Test View For Relation Traversal Filter',
objectMetadataId: personObject.id,
type: ViewType.TABLE,
icon: 'IconUser',
},
});
createdViewId = viewData?.createView?.id;
jestExpectToBeDefined(createdViewId);
});
afterAll(async () => {
if (createdViewId) {
await destroyOneView({
expectToFail: false,
viewId: createdViewId,
});
}
});
afterEach(async () => {
if (createdViewFilterId) {
await destroyOneViewFilter({
expectToFail: false,
input: { id: createdViewFilterId },
});
createdViewFilterId = '';
}
});
it('should persist relationTargetFieldMetadataId on create', async () => {
const { data } = await createOneViewFilter({
expectToFail: false,
input: {
viewId: createdViewId,
fieldMetadataId: personCompanyRelationFieldId,
operand: ViewFilterOperand.CONTAINS,
value: 'Acme',
relationTargetFieldMetadataId: companyNameFieldId,
},
gqlFields: RELATION_TRAVERSAL_GQL_FIELDS,
});
createdViewFilterId = data?.createViewFilter?.id;
expect(data.createViewFilter).toMatchObject({
id: expect.any(String),
viewId: createdViewId,
fieldMetadataId: personCompanyRelationFieldId,
relationTargetFieldMetadataId: companyNameFieldId,
operand: ViewFilterOperand.CONTAINS,
value: 'Acme',
});
});
it('should default relationTargetFieldMetadataId to null when not provided', async () => {
const { data } = await createOneViewFilter({
expectToFail: false,
input: {
viewId: createdViewId,
fieldMetadataId: personCompanyRelationFieldId,
operand: ViewFilterOperand.CONTAINS,
value: 'Acme',
},
gqlFields: RELATION_TRAVERSAL_GQL_FIELDS,
});
createdViewFilterId = data?.createViewFilter?.id;
expect(data.createViewFilter).toMatchObject({
id: expect.any(String),
viewId: createdViewId,
fieldMetadataId: personCompanyRelationFieldId,
relationTargetFieldMetadataId: null,
});
});
it('should update relationTargetFieldMetadataId on an existing filter', async () => {
const { data: createData } = await createOneViewFilter({
expectToFail: false,
input: {
viewId: createdViewId,
fieldMetadataId: personCompanyRelationFieldId,
operand: ViewFilterOperand.CONTAINS,
value: 'Acme',
relationTargetFieldMetadataId: companyNameFieldId,
},
gqlFields: RELATION_TRAVERSAL_GQL_FIELDS,
});
createdViewFilterId = createData?.createViewFilter?.id;
jestExpectToBeDefined(createdViewFilterId);
const { data: updateData } = await updateOneViewFilter({
expectToFail: false,
input: {
id: createdViewFilterId,
update: {
relationTargetFieldMetadataId: companyDomainNameFieldId,
},
},
gqlFields: RELATION_TRAVERSAL_GQL_FIELDS,
});
expect(updateData.updateViewFilter).toMatchObject({
id: createdViewFilterId,
fieldMetadataId: personCompanyRelationFieldId,
relationTargetFieldMetadataId: companyDomainNameFieldId,
});
});
it('should preserve relationTargetFieldMetadataId when an unrelated property is updated', async () => {
const { data: createData } = await createOneViewFilter({
expectToFail: false,
input: {
viewId: createdViewId,
fieldMetadataId: personCompanyRelationFieldId,
operand: ViewFilterOperand.CONTAINS,
value: 'Acme',
relationTargetFieldMetadataId: companyNameFieldId,
},
gqlFields: RELATION_TRAVERSAL_GQL_FIELDS,
});
createdViewFilterId = createData?.createViewFilter?.id;
jestExpectToBeDefined(createdViewFilterId);
const { data: updateData } = await updateOneViewFilter({
expectToFail: false,
input: {
id: createdViewFilterId,
update: {
value: 'Acme Corp',
},
},
gqlFields: RELATION_TRAVERSAL_GQL_FIELDS,
});
expect(updateData.updateViewFilter).toMatchObject({
id: createdViewFilterId,
value: 'Acme Corp',
relationTargetFieldMetadataId: companyNameFieldId,
});
});
});
@@ -919,4 +919,92 @@ describe('turnRecordFilterIntoRecordGqlOperationFilter', () => {
expect(result).toHaveProperty('recordId.in');
});
});
describe('relation traversal', () => {
// The dispatcher should wrap the inner filter under the relation source
// field's GraphQL key and build it against the target field's type
// (not the relation FK's type).
it('should nest filter under source field name when target field is set', () => {
const result = turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies,
recordFilter: {
...makeFilter('f-relation', RecordFilterOperand.CONTAINS, 'Acme'),
relationTargetFieldMetadataId: 'f-text',
} as RecordFilter,
fieldMetadataItems: fields,
});
expect(result).toEqual({ company: { name: { ilike: '%Acme%' } } });
});
// If the target field is no longer in fieldMetadataItems (e.g. it was
// deleted from the workspace), dropping the filter is the safe path —
// the alternative would silently interpret the text value as a UUID
// list against the relation FK.
it('should return undefined when target field is not found', () => {
const result = turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies,
recordFilter: {
...makeFilter('f-relation', RecordFilterOperand.CONTAINS, 'Acme'),
relationTargetFieldMetadataId: 'nonexistent-target',
} as RecordFilter,
fieldMetadataItems: fields,
});
expect(result).toBeUndefined();
});
// Emptiness operands must check the target column on the related
// object rather than the FK column on the source record.
it('should apply IS_EMPTY against the target field, not the relation FK', () => {
const result = turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies,
recordFilter: {
...makeFilter('f-relation', RecordFilterOperand.IS_EMPTY, ''),
relationTargetFieldMetadataId: 'f-text',
} as RecordFilter,
fieldMetadataItems: fields,
});
// Without traversal, the RELATION case would have produced a filter
// on `companyId`; here we expect the emptiness check to fall on
// `company.name` instead.
expect(result).toEqual({
company: { or: [{ name: { ilike: '' } }, { name: { is: 'NULL' } }] },
});
});
// The target field's per-type switch must run, so number/select/etc.
// targets behave like a direct filter on that field — verified here
// with a SELECT target.
it('should dispatch to target type switch (SELECT target)', () => {
const result = turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies,
recordFilter: {
...makeFilter('f-relation', RecordFilterOperand.IS, '["ACTIVE"]'),
relationTargetFieldMetadataId: 'f-select',
} as RecordFilter,
fieldMetadataItems: fields,
});
expect(result).toEqual({ company: { status: { in: ['ACTIVE'] } } });
});
// Without a relationTargetFieldMetadataId the dispatcher must fall
// through to the direct builder — preserving the filter-by-record-id
// behaviour on the relation FK.
it('should keep relation filter-by-id behaviour when no target field is set', () => {
const result = turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies,
recordFilter: makeFilter(
'f-relation',
RecordFilterOperand.IS,
'["550e8400-e29b-41d4-a716-446655440000"]',
),
fieldMetadataItems: fields,
});
expect(result).toHaveProperty('companyId.in');
});
});
});
@@ -19,6 +19,7 @@ export type RecordFilter = {
recordFilterGroupId?: string | null;
operand: ViewFilterOperand;
subFieldName?: CompositeFieldSubFieldName | null | undefined;
relationTargetFieldMetadataId?: string | null | undefined;
};
export type RecordFilterGroup = {
@@ -75,11 +75,11 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
}: TurnRecordFilterIntoRecordGqlOperationFilterParams):
| RecordGqlOperationFilter
| undefined => {
const correspondingFieldMetadataItem = fieldMetadataItems.find(
const sourceFieldMetadataItem = fieldMetadataItems.find(
(field) => field.id === recordFilter.fieldMetadataId,
);
if (!isDefined(correspondingFieldMetadataItem)) {
if (!isDefined(sourceFieldMetadataItem)) {
return;
}
@@ -87,15 +87,66 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return;
}
if (
sourceFieldMetadataItem.type === FieldMetadataType.RELATION &&
isDefined(recordFilter.relationTargetFieldMetadataId)
) {
const targetFieldMetadataItem = fieldMetadataItems.find(
(field) => field.id === recordFilter.relationTargetFieldMetadataId,
);
if (!isDefined(targetFieldMetadataItem)) {
return;
}
const innerFilter = buildDirectFieldGqlOperationFilter({
recordFilter: {
...recordFilter,
fieldMetadataId: targetFieldMetadataItem.id,
relationTargetFieldMetadataId: null,
},
fieldMetadataItem: targetFieldMetadataItem,
filterValueDependencies,
});
if (!isDefined(innerFilter)) {
return;
}
return {
[sourceFieldMetadataItem.name]: innerFilter,
} as RecordGqlOperationFilter;
}
return buildDirectFieldGqlOperationFilter({
recordFilter,
fieldMetadataItem: sourceFieldMetadataItem,
filterValueDependencies,
});
};
type BuildDirectFieldGqlOperationFilterParams = {
filterValueDependencies: RecordFilterValueDependencies;
recordFilter: Omit<RecordFilter, 'id'>;
fieldMetadataItem: FieldShared;
};
const buildDirectFieldGqlOperationFilter = ({
recordFilter,
fieldMetadataItem,
filterValueDependencies,
}: BuildDirectFieldGqlOperationFilterParams):
| RecordGqlOperationFilter
| undefined => {
const shouldComputeEmptinessFilter = checkIfShouldComputeEmptinessFilter({
recordFilterOperand: recordFilter.operand,
correspondingFieldMetadataItem,
correspondingFieldMetadataItem: fieldMetadataItem,
});
if (shouldComputeEmptinessFilter) {
const emptinessFilter = getEmptyRecordGqlOperationFilter({
operand: recordFilter.operand,
correspondingField: correspondingFieldMetadataItem,
correspondingField: fieldMetadataItem,
recordFilter: recordFilter,
});
@@ -106,23 +157,21 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
const isSubFieldFilter = isNonEmptyString(subFieldName);
const filterType = getFilterTypeFromFieldType(
correspondingFieldMetadataItem.type,
);
const filterType = getFilterTypeFromFieldType(fieldMetadataItem.type);
switch (filterType) {
case 'TEXT':
switch (recordFilter.operand) {
case RecordFilterOperand.CONTAINS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
ilike: `%${recordFilter.value}%`,
} as StringFilter,
};
case RecordFilterOperand.DOES_NOT_CONTAIN:
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
ilike: `%${recordFilter.value}%`,
} as StringFilter,
},
@@ -137,7 +186,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.VECTOR_SEARCH:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
search: recordFilter.value,
} as TSVectorFilter,
};
@@ -150,14 +199,14 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.CONTAINS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
like: `%${recordFilter.value}%`,
} as RawJsonFilter,
};
case RecordFilterOperand.DOES_NOT_CONTAIN:
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
like: `%${recordFilter.value}%`,
} as RawJsonFilter,
},
@@ -171,14 +220,14 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.CONTAINS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
like: `%${recordFilter.value}%`,
} as FilesFilter,
};
case RecordFilterOperand.DOES_NOT_CONTAIN:
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
like: `%${recordFilter.value}%`,
} as FilesFilter,
},
@@ -215,12 +264,12 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return {
and: [
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
gte: start,
} as DateFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
lt: end,
} as DateFilter,
},
@@ -241,19 +290,19 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.IS_IN_PAST:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
lt: nowAsPlainDate,
} as DateFilter,
};
case RecordFilterOperand.IS_IN_FUTURE:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
gte: nowAsPlainDate,
} as DateFilter,
};
case RecordFilterOperand.IS_TODAY: {
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
eq: nowAsPlainDate,
} as DateFilter,
};
@@ -265,14 +314,14 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.IS_AFTER: {
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
gte: plainDateFilter,
} as DateFilter,
};
}
case RecordFilterOperand.IS_BEFORE: {
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
lt: plainDateFilter,
} as DateFilter,
};
@@ -280,7 +329,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
case RecordFilterOperand.IS: {
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
eq: plainDateFilter,
} as DateFilter,
};
@@ -331,12 +380,12 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return {
and: [
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
gte: start.toInstant().toString(),
} as DateTimeFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
lt: end.toInstant().toString(),
} as DateTimeFilter,
},
@@ -357,13 +406,13 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.IS_IN_PAST:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
lt: now.toInstant().round('minute').toString(),
} as DateTimeFilter,
};
case RecordFilterOperand.IS_IN_FUTURE:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
gt: now.toInstant().round('minute').toString(),
} as DateTimeFilter,
};
@@ -371,12 +420,12 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return {
and: [
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
gte: getPeriodStart(now, 'DAY').toInstant().toString(),
} as DateTimeFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
lt: getNextPeriodStart(now, 'DAY').toInstant().toString(),
} as DateTimeFilter,
},
@@ -413,12 +462,12 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return {
and: [
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
gte: start.toString(),
} as DateTimeFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
lt: end.toString(),
} as DateTimeFilter,
},
@@ -431,14 +480,14 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.IS_AFTER: {
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
gte: resolvedDateTime.toString(),
} as DateTimeFilter,
};
}
case RecordFilterOperand.IS_BEFORE: {
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
lt: resolvedDateTime.toString(),
} as DateTimeFilter,
};
@@ -454,13 +503,13 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.IS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
eq: convertRatingToRatingValue(parseFloat(recordFilter.value)),
} as RatingFilter,
};
case RecordFilterOperand.GREATER_THAN_OR_EQUAL:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
in: convertGreaterThanOrEqualRatingToArrayOfRatingValues(
parseFloat(recordFilter.value),
),
@@ -468,7 +517,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
};
case RecordFilterOperand.LESS_THAN_OR_EQUAL:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
in: convertLessThanOrEqualRatingToArrayOfRatingValues(
parseFloat(recordFilter.value),
),
@@ -483,26 +532,26 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.GREATER_THAN_OR_EQUAL:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
gte: parseFloat(recordFilter.value),
} as FloatFilter,
};
case RecordFilterOperand.LESS_THAN_OR_EQUAL:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
lte: parseFloat(recordFilter.value),
} as FloatFilter,
};
case RecordFilterOperand.IS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
eq: parseFloat(recordFilter.value),
} as FloatFilter,
};
case RecordFilterOperand.IS_NOT:
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
eq: parseFloat(recordFilter.value),
} as FloatFilter,
},
@@ -535,7 +584,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.IS:
return {
[correspondingFieldMetadataItem.name + 'Id']: {
[fieldMetadataItem.name + 'Id']: {
in: recordIds,
} as RelationFilter,
};
@@ -545,13 +594,13 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name + 'Id']: {
[fieldMetadataItem.name + 'Id']: {
in: recordIds,
} as RelationFilter,
},
},
{
[correspondingFieldMetadataItem.name + 'Id']: {
[fieldMetadataItem.name + 'Id']: {
is: 'NULL',
} as RelationFilter,
},
@@ -579,7 +628,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
if (parsedCurrencyCodes.length === 0) return undefined;
const gqlFilter: RecordGqlOperationFilter = {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
currencyCode: { in: parsedCurrencyCodes },
} as CurrencyFilter,
};
@@ -607,26 +656,26 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.GREATER_THAN_OR_EQUAL:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
amountMicros: { gte: parseFloat(recordFilter.value) * 1000000 },
} as CurrencyFilter,
};
case RecordFilterOperand.LESS_THAN_OR_EQUAL:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
amountMicros: { lte: parseFloat(recordFilter.value) * 1000000 },
} as CurrencyFilter,
};
case RecordFilterOperand.IS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
amountMicros: { eq: parseFloat(recordFilter.value) * 1000000 },
} as CurrencyFilter,
};
case RecordFilterOperand.IS_NOT:
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
amountMicros: {
eq: parseFloat(recordFilter.value) * 1000000,
},
@@ -646,7 +695,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
}
case 'LINKS': {
return computeGqlOperationFilterForLinks({
correspondingFieldMetadataItem,
correspondingFieldMetadataItem: fieldMetadataItem,
recordFilter,
subFieldName,
});
@@ -654,7 +703,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
case 'FULL_NAME': {
const fullNameFilters = generateILikeFiltersForCompositeFields(
recordFilter.value,
correspondingFieldMetadataItem.name,
fieldMetadataItem.name,
['firstName', 'lastName'],
);
switch (recordFilter.operand) {
@@ -665,7 +714,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
};
} else {
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
[subFieldName]: {
ilike: `%${recordFilter.value}%`,
},
@@ -684,7 +733,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
} else {
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
[subFieldName]: {
ilike: `%${recordFilter.value}%`,
},
@@ -705,42 +754,42 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressStreet1: {
ilike: `%${recordFilter.value}%`,
},
} as AddressFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressStreet2: {
ilike: `%${recordFilter.value}%`,
},
} as AddressFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressCity: {
ilike: `%${recordFilter.value}%`,
},
} as AddressFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressState: {
ilike: `%${recordFilter.value}%`,
},
} as AddressFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressCountry: {
ilike: `%${recordFilter.value}%`,
},
} as AddressFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressPostcode: {
ilike: `%${recordFilter.value}%`,
},
@@ -759,7 +808,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
}
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
[subFieldName]: {
in: parsedCountryCodes,
} as AddressFilter,
@@ -768,7 +817,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
}
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
[subFieldName]: {
ilike: `%${recordFilter.value}%`,
} as AddressFilter,
@@ -783,7 +832,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressStreet1: {
ilike: `%${recordFilter.value}%`,
},
@@ -791,7 +840,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressStreet1: {
is: 'NULL',
},
@@ -803,7 +852,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressStreet2: {
ilike: `%${recordFilter.value}%`,
},
@@ -811,7 +860,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressStreet2: {
is: 'NULL',
},
@@ -823,7 +872,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressCity: {
ilike: `%${recordFilter.value}%`,
},
@@ -831,7 +880,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressCity: {
is: 'NULL',
},
@@ -843,7 +892,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressState: {
ilike: `%${recordFilter.value}%`,
},
@@ -851,7 +900,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressState: {
is: 'NULL',
},
@@ -863,7 +912,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressPostcode: {
ilike: `%${recordFilter.value}%`,
},
@@ -871,7 +920,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressPostcode: {
is: 'NULL',
},
@@ -883,7 +932,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressCountry: {
ilike: `%${recordFilter.value}%`,
},
@@ -891,7 +940,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressCountry: {
is: 'NULL',
},
@@ -918,7 +967,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressCountry: {
in: JSON.parse(recordFilter.value),
} as AddressFilter,
@@ -926,7 +975,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressCountry: {
is: 'NULL',
} as AddressFilter,
@@ -940,7 +989,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
[subFieldName]: {
ilike: `%${recordFilter.value}%`,
} as AddressFilter,
@@ -948,7 +997,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
[subFieldName]: {
is: 'NULL',
} as AddressFilter,
@@ -976,7 +1025,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
if (nonEmptyOptions.length > 0) {
conditions.push({
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
containsAny: nonEmptyOptions,
} as MultiSelectFilter,
});
@@ -984,7 +1033,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
if (emptyOptions.length > 0) {
conditions.push({
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
isEmptyArray: true,
} as MultiSelectFilter,
});
@@ -997,18 +1046,18 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
containsAny: nonEmptyOptions,
} as MultiSelectFilter,
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
isEmptyArray: true,
} as MultiSelectFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
is: 'NULL',
} as MultiSelectFilter,
},
@@ -1034,7 +1083,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
if (nonEmptyOptions.length > 0) {
conditions.push({
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
in: nonEmptyOptions,
} as SelectFilter,
});
@@ -1042,7 +1091,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
if (emptyOptions.length > 0) {
conditions.push({
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
is: 'NULL',
} as SelectFilter,
});
@@ -1056,7 +1105,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
if (nonEmptyOptions.length > 0) {
conditions.push({
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
in: nonEmptyOptions,
} as SelectFilter,
},
@@ -1066,7 +1115,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
if (emptyOptions.length > 0) {
conditions.push({
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
is: 'NULL',
} as SelectFilter,
},
@@ -1085,14 +1134,14 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.CONTAINS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
containsIlike: `%${recordFilter.value}%`,
} as ArrayFilter,
};
case RecordFilterOperand.DOES_NOT_CONTAIN:
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
containsIlike: `%${recordFilter.value}%`,
} as ArrayFilter,
},
@@ -1114,7 +1163,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
const parsedSources = JSON.parse(recordFilter.value) as string[];
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
source: {
in: parsedSources,
} satisfies RelationFilter,
@@ -1132,7 +1181,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
source: {
in: parsedSources,
} satisfies RelationFilter,
@@ -1141,12 +1190,8 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
};
}
default: {
const fieldForRecordFilter = fieldMetadataItems.find(
(field) => field.id === recordFilter.fieldMetadataId,
);
throw new Error(
`Unknown operand ${recordFilter.operand} for ${fieldForRecordFilter?.label ?? ''} filter`,
`Unknown operand ${recordFilter.operand} for ${fieldMetadataItem.label} filter`,
);
}
}
@@ -1177,7 +1222,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.IS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
workspaceMemberId: {
in: workspaceMemberIds,
} satisfies UUIDFilter,
@@ -1188,7 +1233,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
workspaceMemberId: {
in: workspaceMemberIds,
} satisfies UUIDFilter,
@@ -1196,7 +1241,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
workspaceMemberId: {
is: 'NULL',
} satisfies UUIDFilter,
@@ -1206,12 +1251,8 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
};
}
default: {
const fieldForRecordFilter = fieldMetadataItems.find(
(field) => field.id === recordFilter.fieldMetadataId,
);
throw new Error(
`Unknown operand ${recordFilter.operand} for ${fieldForRecordFilter?.label ?? ''} filter`,
`Unknown operand ${recordFilter.operand} for ${fieldMetadataItem.label} filter`,
);
}
}
@@ -1227,7 +1268,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
name: {
ilike: `%${recordFilter.value}%`,
},
@@ -1236,7 +1277,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
...(matchingSourceValues.length > 0
? [
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
source: {
in: matchingSourceValues,
},
@@ -1252,7 +1293,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
and: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
name: {
ilike: `%${recordFilter.value}%`,
},
@@ -1263,7 +1304,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
? [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
source: {
in: matchingSourceValues,
},
@@ -1276,19 +1317,15 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
};
}
default: {
const fieldForRecordFilter = fieldMetadataItems.find(
(field) => field.id === recordFilter.fieldMetadataId,
);
throw new Error(
`Unknown operand ${recordFilter.operand} for ${fieldForRecordFilter?.label ?? ''} filter`,
`Unknown operand ${recordFilter.operand} for ${fieldMetadataItem.label} filter`,
);
}
}
}
case 'EMAILS': {
return computeGqlOperationFilterForEmails({
correspondingFieldMetadataItem,
correspondingFieldMetadataItem: fieldMetadataItem,
recordFilter,
subFieldName,
});
@@ -1306,21 +1343,21 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
primaryPhoneNumber: {
ilike: `%${filterValue}%`,
},
} as PhonesFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
primaryPhoneCallingCode: {
ilike: `%${filterValue}%`,
},
} as PhonesFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
additionalPhones: {
like: `%${filterValue}%`,
},
@@ -1333,7 +1370,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
and: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
primaryPhoneNumber: {
ilike: `%${filterValue}%`,
},
@@ -1342,7 +1379,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
primaryPhoneCallingCode: {
ilike: `%${filterValue}%`,
},
@@ -1353,7 +1390,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
additionalPhones: {
like: `%${filterValue}%`,
},
@@ -1361,7 +1398,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
additionalPhones: {
is: 'NULL',
} as PhonesFilter,
@@ -1387,7 +1424,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
additionalPhones: {
like: `%${filterValue}%`,
},
@@ -1400,7 +1437,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
additionalPhones: {
like: `%${filterValue}%`,
},
@@ -1408,7 +1445,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
additionalPhones: {
is: 'NULL',
} as PhonesFilter,
@@ -1426,7 +1463,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.CONTAINS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
primaryPhoneNumber: {
ilike: `%${filterValue}%`,
},
@@ -1435,7 +1472,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
case RecordFilterOperand.DOES_NOT_CONTAIN:
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
primaryPhoneNumber: {
ilike: `%${filterValue}%`,
},
@@ -1452,7 +1489,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.CONTAINS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
primaryPhoneCallingCode: {
ilike: `%${filterValue}%`,
},
@@ -1461,7 +1498,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
case RecordFilterOperand.DOES_NOT_CONTAIN:
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
primaryPhoneCallingCode: {
ilike: `%${filterValue}%`,
},
@@ -1482,7 +1519,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
}
case 'BOOLEAN': {
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
eq: recordFilter.value === 'true',
} as BooleanFilter,
};
@@ -1495,7 +1532,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.IS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
in: recordIds,
} as UUIDFilter,
};