From 1ac87d106e497ad7ea71c917386e29c6f50f9e40 Mon Sep 17 00:00:00 2001 From: Weiko Date: Mon, 23 Feb 2026 19:25:58 +0100 Subject: [PATCH] With RLS predicates on Select, only show possible values in option picker (#18166) ## Context Removing SELECT/MULTI_SELECT options that can't be selected due to RLS (see https://github.com/twentyhq/core-team-issues/issues/2226) Screenshot 2026-02-23 at 11 30 21 Screenshot 2026-02-23 at 11 30 03 --- ...eFilteredSelectOptionsFromRLSPredicates.ts | 154 ++++++++++++++++++ .../components/MultiSelectFieldInput.tsx | 11 +- .../input/components/SelectFieldInput.tsx | 17 +- 3 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilteredSelectOptionsFromRLSPredicates.ts diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilteredSelectOptionsFromRLSPredicates.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilteredSelectOptionsFromRLSPredicates.ts new file mode 100644 index 0000000000..c1087b5708 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useFilteredSelectOptionsFromRLSPredicates.ts @@ -0,0 +1,154 @@ +/* @license Enterprise */ + +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject'; +import { useMemo } from 'react'; +import { + type RowLevelPermissionPredicate, + RowLevelPermissionPredicateOperand, +} from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { type SelectOption } from 'twenty-ui/input'; + +// Predicate values for select fields can be: +// - an actual array: ["BIRD", "DOG"] +// - a JSON-stringified array: "[\"BIRD\",\"DOG\"]" +// - a plain string: "BIRD" +const parsePredicateValueAsStringArray = ( + value: RowLevelPermissionPredicate['value'], +): string[] | null => { + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string'); + } + + if (typeof value === 'string') { + if (value.startsWith('[')) { + try { + const parsed: unknown = JSON.parse(value); + + if ( + Array.isArray(parsed) && + parsed.every((item) => typeof item === 'string') + ) { + return parsed; + } + } catch { + // not valid JSON, treat as single value + } + } + + return [value]; + } + + return null; +}; + +const extractAllowedValuesFromPredicate = ( + predicate: RowLevelPermissionPredicate, +): { type: 'include' | 'exclude'; values: string[] } | null => { + const { operand, value } = predicate; + const values = parsePredicateValueAsStringArray(value); + + if (!isDefined(values)) { + return null; + } + + switch (operand) { + case RowLevelPermissionPredicateOperand.IS: + case RowLevelPermissionPredicateOperand.CONTAINS: + return { type: 'include', values }; + case RowLevelPermissionPredicateOperand.IS_NOT: + case RowLevelPermissionPredicateOperand.DOES_NOT_CONTAIN: + return { type: 'exclude', values }; + default: + return null; + } +}; + +const filterOptionsByPredicates = ( + options: SelectOption[], + predicates: RowLevelPermissionPredicate[], +): SelectOption[] => { + if (predicates.length === 0) { + return options; + } + + let filteredOptions = options; + + for (const predicate of predicates) { + const result = extractAllowedValuesFromPredicate(predicate); + + if (!isDefined(result)) { + continue; + } + + if (result.type === 'include') { + filteredOptions = filteredOptions.filter((option) => + result.values.includes(option.value), + ); + } else { + filteredOptions = filteredOptions.filter( + (option) => !result.values.includes(option.value), + ); + } + } + + return filteredOptions; +}; + +export const useFilteredSelectOptionsFromRLSPredicates = ({ + fieldMetadataId, + objectMetadataNameSingular, + options, +}: { + fieldMetadataId: string; + objectMetadataNameSingular: string | undefined; + options: SelectOption[]; +}): { filteredOptions: SelectOption[]; canSelectEmpty: boolean } => { + const { objectMetadataItems } = useObjectMetadataItems(); + + const objectMetadataId = objectMetadataNameSingular + ? objectMetadataItems.find( + (item) => item.nameSingular === objectMetadataNameSingular, + )?.id + : undefined; + + const objectPermissions = useObjectPermissionsForObject( + objectMetadataId ?? '', + ); + + return useMemo(() => { + if (!isDefined(objectMetadataId)) { + return { filteredOptions: options, canSelectEmpty: true }; + } + + const selectPredicates = + objectPermissions.rowLevelPermissionPredicates.filter( + (predicate) => predicate.fieldMetadataId === fieldMetadataId, + ); + + if (selectPredicates.length === 0) { + return { filteredOptions: options, canSelectEmpty: true }; + } + + const hasIsEmptyPredicate = selectPredicates.some( + (predicate) => + predicate.operand === RowLevelPermissionPredicateOperand.IS_EMPTY, + ); + + const hasIsNotEmptyPredicate = selectPredicates.some( + (predicate) => + predicate.operand === RowLevelPermissionPredicateOperand.IS_NOT_EMPTY, + ); + + return { + filteredOptions: filterOptionsByPredicates(options, selectPredicates), + canSelectEmpty: hasIsEmptyPredicate && !hasIsNotEmptyPredicate, + }; + }, [ + objectMetadataId, + objectPermissions.rowLevelPermissionPredicates, + fieldMetadataId, + options, + ]); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiSelectFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiSelectFieldInput.tsx index 572f6e82b3..c9aefa38fa 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiSelectFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiSelectFieldInput.tsx @@ -1,6 +1,7 @@ import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext'; import { useAddSelectOption } from '@/object-record/record-field/ui/meta-types/hooks/useAddSelectOption'; import { useCanAddSelectOption } from '@/object-record/record-field/ui/meta-types/hooks/useCanAddSelectOption'; +import { useFilteredSelectOptionsFromRLSPredicates } from '@/object-record/record-field/ui/meta-types/hooks/useFilteredSelectOptionsFromRLSPredicates'; import { useMultiSelectField } from '@/object-record/record-field/ui/meta-types/hooks/useMultiSelectField'; import { SELECT_FIELD_INPUT_SELECTABLE_LIST_COMPONENT_INSTANCE_ID } from '@/object-record/record-field/ui/meta-types/input/constants/SelectFieldInputSelectableListComponentInstanceId'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext'; @@ -20,6 +21,14 @@ export const MultiSelectFieldInput = () => { const { onSubmit } = useContext(FieldInputEventContext); + const { filteredOptions: selectOptions } = + useFilteredSelectOptionsFromRLSPredicates({ + fieldMetadataId: fieldDefinition.fieldMetadataId, + objectMetadataNameSingular: + fieldDefinition.metadata.objectMetadataNameSingular, + options: fieldDefinition.metadata.options, + }); + const handleOptionSelected = (newDraftValue: FieldMultiSelectValue) => { setDraftValue(newDraftValue); }; @@ -45,7 +54,7 @@ export const MultiSelectFieldInput = () => { SELECT_FIELD_INPUT_SELECTABLE_LIST_COMPONENT_INSTANCE_ID } focusId={instanceId} - options={fieldDefinition.metadata.options} + options={selectOptions} onCancel={handleCancel} onOptionSelected={handleOptionSelected} values={draftValue} diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/SelectFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/SelectFieldInput.tsx index 3a3dc57ade..8d512599d1 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/SelectFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/SelectFieldInput.tsx @@ -3,6 +3,7 @@ import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts import { useClearField } from '@/object-record/record-field/ui/hooks/useClearField'; import { useAddSelectOption } from '@/object-record/record-field/ui/meta-types/hooks/useAddSelectOption'; import { useCanAddSelectOption } from '@/object-record/record-field/ui/meta-types/hooks/useCanAddSelectOption'; +import { useFilteredSelectOptionsFromRLSPredicates } from '@/object-record/record-field/ui/meta-types/hooks/useFilteredSelectOptionsFromRLSPredicates'; import { useSelectField } from '@/object-record/record-field/ui/meta-types/hooks/useSelectField'; import { SELECT_FIELD_INPUT_SELECTABLE_LIST_COMPONENT_INSTANCE_ID } from '@/object-record/record-field/ui/meta-types/input/constants/SelectFieldInputSelectableListComponentInstanceId'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext'; @@ -26,6 +27,14 @@ export const SelectFieldInput = () => { const { onCancel, onSubmit } = useContext(FieldInputEventContext); + const { filteredOptions: selectOptions, canSelectEmpty } = + useFilteredSelectOptionsFromRLSPredicates({ + fieldMetadataId: fieldDefinition.fieldMetadataId, + objectMetadataNameSingular: + fieldDefinition.metadata.objectMetadataNameSingular, + options: fieldDefinition.metadata.options, + }); + const instanceId = useAvailableComponentInstanceIdOrThrow( RecordFieldComponentInstanceContext, ); @@ -37,7 +46,7 @@ export const SelectFieldInput = () => { ); const clearField = useClearField(); - const selectedOption = fieldDefinition.metadata.options.find( + const selectedOption = selectOptions.find( (option) => option.value === fieldValue, ); // handlers @@ -91,12 +100,14 @@ export const SelectFieldInput = () => { } }} onOptionSelected={handleSubmit} - options={fieldDefinition.metadata.options} + options={selectOptions} onCancel={onCancel} defaultOption={selectedOption} onFilterChange={setFilteredOptions} onClear={ - fieldDefinition.metadata.isNullable ? handleClearField : undefined + fieldDefinition.metadata.isNullable && canSelectEmpty + ? handleClearField + : undefined } clearLabel={fieldDefinition.label} onAddSelectOption={handleAddSelectOption}