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)

<img width="521" height="276" alt="Screenshot 2026-02-23 at 11 30 21"
src="https://github.com/user-attachments/assets/238ff596-cd8c-4660-a709-3eb2486e23b4"
/>
<img width="279" height="200" alt="Screenshot 2026-02-23 at 11 30 03"
src="https://github.com/user-attachments/assets/7a627d74-7519-4314-9a0e-0890cb8cbf30"
/>
This commit is contained in:
Weiko
2026-02-23 19:25:58 +01:00
committed by GitHub
parent 9a0295fd3d
commit 1ac87d106e
3 changed files with 178 additions and 4 deletions
@@ -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,
]);
};
@@ -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}
@@ -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}