diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormCompositeFieldInput.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormCompositeFieldInput.tsx index 6a2e2a968e..19efbb5275 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormCompositeFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormCompositeFieldInput.tsx @@ -1,11 +1,11 @@ -import { CoreObjectNameSingular, FieldActorSource } from 'twenty-shared/types'; +import { FieldActorSource } from 'twenty-shared/types'; import { AdvancedFilterContext } from '@/object-record/advanced-filter/states/context/AdvancedFilterContext'; import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState'; import { FormCountryMultiSelectInput } from '@/object-record/record-field/ui/form-types/components/FormCountryMultiSelectInput'; import { FormMultiSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiSelectFieldInput'; import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types/components/FormNumberFieldInput'; -import { FormRelationToOneFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRelationToOneFieldInput'; import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput'; +import { FormWorkspaceMemberFilterValueInput } from '@/object-record/record-field/ui/form-types/components/FormWorkspaceMemberFilterValueInput'; import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { CURRENCIES } from '@/settings/data-model/constants/Currencies'; @@ -101,8 +101,7 @@ export const AdvancedFilterSidePanelValueFormCompositeFieldInput = ({ VariablePicker={VariablePicker} /> ) : recordFilter.subFieldName === 'workspaceMemberId' ? ( - ` + cursor: ${({ readonly }) => (readonly ? 'default' : 'pointer')}; + display: flex; + height: 32px; + width: 100%; +`; + +const StyledIconButton = styled.div` + display: flex; + padding-right: ${themeCssVariables.spacing[2]}; +`; + +export type FormWorkspaceMemberFilterValueInputProps = { + label?: string; + defaultValue?: string | null; + onChange: (value: JsonValue) => void; + onClear?: () => void; + readonly?: boolean; + VariablePicker?: VariablePickerComponent; +}; + +export const FormWorkspaceMemberFilterValueInput = ({ + label, + defaultValue, + onChange, + onClear, + readonly, + VariablePicker, +}: FormWorkspaceMemberFilterValueInputProps) => { + const { theme } = useContext(ThemeContext); + + const componentId = useId(); + const dropdownId = `form-workspace-member-filter-picker-${componentId}`; + const selectableListId = `${dropdownId}-selectable-list`; + const variablesDropdownId = `${dropdownId}-variables`; + + const [searchFilter, setSearchFilter] = useState(''); + + const isVariableValue = isStandaloneVariableString(defaultValue); + + const parsedValue = useMemo( + () => + isVariableValue + ? { + isCurrentWorkspaceMemberSelected: false, + selectedRecordIds: [] as string[], + } + : parseWorkspaceMemberFilterValue(defaultValue), + [defaultValue, isVariableValue], + ); + + const { isCurrentWorkspaceMemberSelected, selectedRecordIds } = parsedValue; + + const { loading, filteredSelectedRecords, recordsToSelect, selectedRecords } = + useRecordsForSelect({ + searchFilterText: searchFilter, + selectedIds: selectedRecordIds, + objectNameSingular: CoreObjectNameSingular.WorkspaceMember, + limit: 10, + allowRequestsToTwentyIcons: false, + }); + + const emitChange = useCallback( + (next: ParsedWorkspaceMemberFilterValue) => { + const jsonValue = buildWorkspaceMemberFilterJsonValue(next); + + if (jsonValue === '' && onClear) { + onClear(); + return; + } + + onChange(jsonValue); + }, + [onChange, onClear], + ); + + const meSelectableItem: SelectableItem = useMemo( + () => ({ + id: CURRENT_WORKSPACE_MEMBER_SELECTABLE_ITEM_ID, + name: t`Me`, + isSelected: isCurrentWorkspaceMemberSelected, + AvatarIcon: IconUserCircle, + }), + [isCurrentWorkspaceMemberSelected], + ); + + const filteredPinnedSelectableItems = useMemo( + () => + [meSelectableItem].filter((item) => + item.name.toLowerCase().includes(searchFilter.toLowerCase()), + ), + [meSelectableItem, searchFilter], + ); + + const handleSelectChange = useCallback( + (itemToSelect: SelectableItem, isNewSelectedValue: boolean) => { + if (loading) { + return; + } + + const isItemCurrentWorkspaceMember = + itemToSelect.id === CURRENT_WORKSPACE_MEMBER_SELECTABLE_ITEM_ID; + + const nextIsCurrentWorkspaceMemberSelected = isItemCurrentWorkspaceMember + ? isNewSelectedValue + : isCurrentWorkspaceMemberSelected; + + const nextSelectedRecordIds = isItemCurrentWorkspaceMember + ? selectedRecordIds + : isNewSelectedValue + ? [...selectedRecordIds, itemToSelect.id] + : selectedRecordIds.filter((id) => id !== itemToSelect.id); + + emitChange({ + isCurrentWorkspaceMemberSelected: nextIsCurrentWorkspaceMemberSelected, + selectedRecordIds: nextSelectedRecordIds, + }); + }, + [emitChange, isCurrentWorkspaceMemberSelected, loading, selectedRecordIds], + ); + + const handleVariableTagInsert = useCallback( + (variable: string) => { + onChange(variable); + }, + [onChange], + ); + + const handleUnlinkVariable = useCallback(() => { + if (isDefined(onClear)) { + onClear(); + return; + } + + onChange(''); + }, [onChange, onClear]); + + const handleSearchChange = useCallback( + (event: ChangeEvent) => { + setSearchFilter(event.target.value); + }, + [], + ); + + const handleDropdownClose = useCallback(() => { + setSearchFilter(''); + }, []); + + const triggerDisplayText = useMemo(() => { + const selectedRecordNames = [ + ...recordsToSelect, + ...selectedRecords, + ...filteredSelectedRecords, + ] + .filter( + (record, index, self) => + self.findIndex((other) => other.id === record.id) === index, + ) + .filter((record) => selectedRecordIds.includes(record.id)) + .map((record) => record.name); + + const selectedPinnedItemNames = isCurrentWorkspaceMemberSelected + ? [t`Me`] + : []; + + const selectedItemNames = [ + ...selectedPinnedItemNames, + ...selectedRecordNames, + ]; + + if (selectedItemNames.length === 0) { + return null; + } + + if (selectedItemNames.length > MAX_WORKSPACE_MEMBERS_TO_DISPLAY) { + return t`${selectedItemNames.length} workspace members`; + } + + return selectedItemNames.join(', '); + }, [ + filteredSelectedRecords, + isCurrentWorkspaceMemberSelected, + recordsToSelect, + selectedRecordIds, + selectedRecords, + ]); + + const triggerContent = ( + + ); + + return ( + + {label ? {label} : null} + + {readonly ? ( + + + {triggerContent} + + + ) : ( + + + {triggerContent} + + + + + + } + dropdownComponents={ + + } + /> + )} + {isDefined(VariablePicker) && !readonly && ( + + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormWorkspaceMemberFilterValueInputDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormWorkspaceMemberFilterValueInputDropdownContent.tsx new file mode 100644 index 0000000000..894780536d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormWorkspaceMemberFilterValueInputDropdownContent.tsx @@ -0,0 +1,64 @@ +import { type ChangeEvent } from 'react'; + +import { ObjectFilterDropdownRecordPinnedItems } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordPinnedItems'; +import { MultipleSelectDropdown } from '@/object-record/select/components/MultipleSelectDropdown'; +import { type SelectableItem } from '@/object-record/select/types/SelectableItem'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; +import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; + +type FormWorkspaceMemberFilterValueInputDropdownContentProps = { + dropdownId: string; + selectableListId: string; + searchFilter: string; + onSearchChange: (event: ChangeEvent) => void; + pinnedSelectableItems: SelectableItem[]; + recordsToSelect: SelectableItem[]; + filteredSelectedRecords: SelectableItem[]; + selectedRecords: SelectableItem[]; + loading: boolean; + onSelectChange: (item: SelectableItem, isNewSelectedValue: boolean) => void; +}; + +export const FormWorkspaceMemberFilterValueInputDropdownContent = ({ + dropdownId, + selectableListId, + searchFilter, + onSearchChange, + pinnedSelectableItems, + recordsToSelect, + filteredSelectedRecords, + selectedRecords, + loading, + onSelectChange, +}: FormWorkspaceMemberFilterValueInputDropdownContentProps) => ( + + + + {pinnedSelectableItems.length > 0 && ( + <> + + + + )} + + +); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormWorkspaceMemberFilterValueInputTrigger.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormWorkspaceMemberFilterValueInputTrigger.tsx new file mode 100644 index 0000000000..5b635a85c5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormWorkspaceMemberFilterValueInputTrigger.tsx @@ -0,0 +1,67 @@ +import { styled } from '@linaria/react'; +import { t } from '@lingui/core/macro'; +import { isDefined } from 'twenty-shared/utils'; +import { IconUserCircle } from 'twenty-ui/display'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { FormFieldPlaceholder } from '@/object-record/record-field/ui/form-types/components/FormFieldPlaceholder'; +import { VariableChipStandalone } from '@/object-record/record-field/ui/form-types/components/VariableChipStandalone'; + +const StyledTriggerLabel = styled.div` + align-items: center; + color: ${themeCssVariables.font.color.primary}; + display: flex; + flex: 1; + gap: ${themeCssVariables.spacing[1]}; + margin: ${themeCssVariables.spacing[2]}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const StyledPlaceholderContainer = styled.div` + margin: ${themeCssVariables.spacing[2]}; +`; + +type FormWorkspaceMemberFilterValueInputTriggerProps = { + isVariableValue: boolean; + defaultValue?: string | null; + isCurrentWorkspaceMemberSelected: boolean; + triggerDisplayText: string | null; + readonly?: boolean; + onUnlinkVariable: () => void; +}; + +export const FormWorkspaceMemberFilterValueInputTrigger = ({ + isVariableValue, + defaultValue, + isCurrentWorkspaceMemberSelected, + triggerDisplayText, + readonly, + onUnlinkVariable, +}: FormWorkspaceMemberFilterValueInputTriggerProps) => { + if (isVariableValue) { + return ( + + ); + } + + if (isDefined(triggerDisplayText)) { + return ( + + {isCurrentWorkspaceMemberSelected && } + {triggerDisplayText} + + ); + } + + return ( + + {t`Select`} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/utils/parseWorkspaceMemberFilterValue.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/utils/parseWorkspaceMemberFilterValue.ts new file mode 100644 index 0000000000..a17938ad08 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/utils/parseWorkspaceMemberFilterValue.ts @@ -0,0 +1,56 @@ +import { + arrayOfUuidOrVariableSchema, + isDefined, + jsonRelationFilterValueSchema, +} from 'twenty-shared/utils'; + +export type ParsedWorkspaceMemberFilterValue = { + isCurrentWorkspaceMemberSelected: boolean; + selectedRecordIds: string[]; +}; + +export const parseWorkspaceMemberFilterValue = ( + rawValue: string | null | undefined, +): ParsedWorkspaceMemberFilterValue => { + if (!isDefined(rawValue) || rawValue === '') { + return { + isCurrentWorkspaceMemberSelected: false, + selectedRecordIds: [], + }; + } + + const fallbackRecordIds = (() => { + try { + return arrayOfUuidOrVariableSchema.parse(rawValue); + } catch { + return [] as string[]; + } + })(); + + const parsed = jsonRelationFilterValueSchema + .catch({ + isCurrentWorkspaceMemberSelected: false, + selectedRecordIds: fallbackRecordIds, + }) + .parse(rawValue); + + return { + isCurrentWorkspaceMemberSelected: + parsed.isCurrentWorkspaceMemberSelected ?? false, + selectedRecordIds: parsed.selectedRecordIds, + }; +}; + +export const buildWorkspaceMemberFilterJsonValue = ({ + isCurrentWorkspaceMemberSelected, + selectedRecordIds, +}: ParsedWorkspaceMemberFilterValue): string => { + if (!isCurrentWorkspaceMemberSelected && selectedRecordIds.length === 0) { + return ''; + } + + return JSON.stringify({ + isCurrentWorkspaceMemberSelected, + selectedRecordIds, + }); +}; diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-data-query.service.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-data-query.service.ts index e29170db39..795455769f 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-data-query.service.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-data-query.service.ts @@ -16,6 +16,7 @@ import { import { ObjectRecordGroupBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { CommonGroupByQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-group-by-query-runner.service'; +import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard'; import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; @@ -91,10 +92,15 @@ export class ChartDataQueryService { secondaryAxisOrderBy, splitMultiValueFields, }: ExecuteGroupByQueryParams): Promise { + const currentWorkspaceMemberId = isUserAuthContext(authContext) + ? authContext.workspaceMemberId + : undefined; + const gqlOperationFilter = convertChartFilterToGqlOperationFilter({ filter, flatFieldMetadataMaps, userTimezone, + currentWorkspaceMemberId, }); const primaryGroupByField = getFieldMetadata( diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/convert-chart-filter-to-gql-operation-filter.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/convert-chart-filter-to-gql-operation-filter.util.spec.ts new file mode 100644 index 0000000000..91f1ec8c86 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/convert-chart-filter-to-gql-operation-filter.util.spec.ts @@ -0,0 +1,95 @@ +import { + type ChartFilter, + FieldMetadataType, + ViewFilterOperand, +} from 'twenty-shared/types'; + +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { convertChartFilterToGqlOperationFilter } from 'src/modules/dashboard/chart-data/utils/convert-chart-filter-to-gql-operation-filter.util'; + +const userTimezone = 'Europe/Paris'; +const currentWorkspaceMemberId = 'd9f0e9c0-1a3b-4f6f-9c1f-7b1d6a2c8b11'; + +const createdByFieldMetadata = { + id: 'created-by-field-id', + name: 'createdBy', + type: FieldMetadataType.RELATION, + universalIdentifier: 'created-by-universal-id', + relationTargetObjectMetadataId: 'workspace-member-object-id', +} as FlatFieldMetadata; + +const flatFieldMetadataMaps = { + byUniversalIdentifier: { + [createdByFieldMetadata.universalIdentifier]: createdByFieldMetadata, + }, + universalIdentifierById: { + [createdByFieldMetadata.id]: createdByFieldMetadata.universalIdentifier, + }, + universalIdentifiersByApplicationId: {}, +} as unknown as FlatEntityMaps; + +describe('convertChartFilterToGqlOperationFilter', () => { + it('returns an empty filter when no filter is provided', () => { + const result = convertChartFilterToGqlOperationFilter({ + filter: undefined, + flatFieldMetadataMaps, + userTimezone, + }); + + expect(result).toEqual({}); + }); + + it('forwards currentWorkspaceMemberId so the "me" filter resolves to the current workspace member', () => { + const filter: ChartFilter = { + recordFilters: [ + { + fieldMetadataId: createdByFieldMetadata.id, + operand: ViewFilterOperand.IS, + value: JSON.stringify({ + isCurrentWorkspaceMemberSelected: true, + selectedRecordIds: [], + }), + type: FieldMetadataType.RELATION, + }, + ], + }; + + const result = convertChartFilterToGqlOperationFilter({ + filter, + flatFieldMetadataMaps, + userTimezone, + currentWorkspaceMemberId, + }); + + expect(result).toEqual({ + [`${createdByFieldMetadata.name}Id`]: { + in: [currentWorkspaceMemberId], + }, + }); + }); + + it('discards a "me" filter when no currentWorkspaceMemberId is provided', () => { + const filter: ChartFilter = { + recordFilters: [ + { + fieldMetadataId: createdByFieldMetadata.id, + operand: ViewFilterOperand.IS, + value: JSON.stringify({ + isCurrentWorkspaceMemberSelected: true, + selectedRecordIds: [], + }), + type: FieldMetadataType.RELATION, + }, + ], + }; + + const result = convertChartFilterToGqlOperationFilter({ + filter, + flatFieldMetadataMaps, + userTimezone, + }); + + expect(result).toEqual({}); + }); +}); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/convert-chart-filter-to-gql-operation-filter.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/convert-chart-filter-to-gql-operation-filter.util.ts index 1a2399a17c..8097024b63 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/utils/convert-chart-filter-to-gql-operation-filter.util.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/convert-chart-filter-to-gql-operation-filter.util.ts @@ -22,12 +22,14 @@ type ConvertChartFilterToGqlOperationFilterParams = { filter: ChartFilter | undefined; flatFieldMetadataMaps: FlatEntityMaps; userTimezone: string; + currentWorkspaceMemberId?: string; }; export const convertChartFilterToGqlOperationFilter = ({ filter, flatFieldMetadataMaps, userTimezone, + currentWorkspaceMemberId, }: ConvertChartFilterToGqlOperationFilterParams): ObjectRecordFilter => { if (!isDefined(filter)) { return {}; @@ -81,6 +83,7 @@ export const convertChartFilterToGqlOperationFilter = ({ recordFilterGroups: convertedRecordFilterGroups, filterValueDependencies: { timeZone: userTimezone, + currentWorkspaceMemberId, }, }); };