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:
+154
@@ -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,
|
||||
]);
|
||||
};
|
||||
+10
-1
@@ -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}
|
||||
|
||||
+14
-3
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user