Support the "Me" filter for workspace members in dashboard widgets and add multi select (#20971)
Fixes https://github.com/twentyhq/twenty/issues/20225 The "Me" filter (current workspace member) worked in view filters but not in dashboard widget filters — the server never resolved the placeholder, and the widget side-panel UI had no "Me" option and only allowed single selection. Backend: `ChartDataQueryService` now forwards the current workspace member id (from authContext) into filterValueDependencies, so the shared filter logic resolves "Me" the same way it does for view filters. Added unit tests for the converter. Frontend: new multi-select picker for workspace member filters in the widget side panel, mirroring the view filter's actor select: search input, "Me" pinned item, and a multi-select workspace member list. ## Before <img width="3024" height="1488" alt="CleanShot 2026-05-27 at 17 16 36@2x" src="https://github.com/user-attachments/assets/b2cff46c-53e5-4e8a-a463-b106daf96c8c" /> ## After <img width="3024" height="1488" alt="CleanShot 2026-05-27 at 17 14 05@2x" src="https://github.com/user-attachments/assets/8b3b5f11-44b9-4ae5-a2f3-9c7a689f4bb2" />
This commit is contained in:
+3
-4
@@ -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' ? (
|
||||
<FormRelationToOneFieldInput
|
||||
objectNameSingular={CoreObjectNameSingular.WorkspaceMember}
|
||||
<FormWorkspaceMemberFilterValueInput
|
||||
defaultValue={recordFilter.value}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
type ChangeEvent,
|
||||
useCallback,
|
||||
useContext,
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconChevronDown, IconUserCircle } from 'twenty-ui/display';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { type JsonValue } from 'type-fest';
|
||||
|
||||
import { MAX_WORKSPACE_MEMBERS_TO_DISPLAY } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownActorSelect';
|
||||
import { CURRENT_WORKSPACE_MEMBER_SELECTABLE_ITEM_ID } from '@/object-record/object-filter-dropdown/constants/CurrentWorkspaceMemberSelectableItemId';
|
||||
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
|
||||
import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer';
|
||||
import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer';
|
||||
import { FormWorkspaceMemberFilterValueInputDropdownContent } from '@/object-record/record-field/ui/form-types/components/FormWorkspaceMemberFilterValueInputDropdownContent';
|
||||
import { FormWorkspaceMemberFilterValueInputTrigger } from '@/object-record/record-field/ui/form-types/components/FormWorkspaceMemberFilterValueInputTrigger';
|
||||
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
|
||||
import {
|
||||
buildWorkspaceMemberFilterJsonValue,
|
||||
type ParsedWorkspaceMemberFilterValue,
|
||||
parseWorkspaceMemberFilterValue,
|
||||
} from '@/object-record/record-field/ui/form-types/utils/parseWorkspaceMemberFilterValue';
|
||||
import { useRecordsForSelect } from '@/object-record/select/hooks/useRecordsForSelect';
|
||||
import { type SelectableItem } from '@/object-record/select/types/SelectableItem';
|
||||
import { InputLabel } from '@/ui/input/components/InputLabel';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
|
||||
|
||||
const StyledFormSelectContainerWrapper = styled.div<{ readonly?: boolean }>`
|
||||
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<HTMLInputElement>) => {
|
||||
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 = (
|
||||
<FormWorkspaceMemberFilterValueInputTrigger
|
||||
isVariableValue={isVariableValue}
|
||||
defaultValue={defaultValue}
|
||||
isCurrentWorkspaceMemberSelected={isCurrentWorkspaceMemberSelected}
|
||||
triggerDisplayText={triggerDisplayText}
|
||||
readonly={readonly}
|
||||
onUnlinkVariable={handleUnlinkVariable}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<FormFieldInputContainer>
|
||||
{label ? <InputLabel>{label}</InputLabel> : null}
|
||||
<FormFieldInputRowContainer>
|
||||
{readonly ? (
|
||||
<StyledFormSelectContainerWrapper readonly>
|
||||
<FormFieldInputInnerContainer
|
||||
formFieldInputInstanceId={componentId}
|
||||
hasRightElement={false}
|
||||
>
|
||||
{triggerContent}
|
||||
</FormFieldInputInnerContainer>
|
||||
</StyledFormSelectContainerWrapper>
|
||||
) : (
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
dropdownPlacement="bottom-start"
|
||||
clickableComponentWidth="100%"
|
||||
onClose={handleDropdownClose}
|
||||
dropdownOffset={{
|
||||
y: parseInt(theme.spacing[1], 10),
|
||||
}}
|
||||
clickableComponent={
|
||||
<StyledFormSelectContainerWrapper>
|
||||
<FormFieldInputInnerContainer
|
||||
formFieldInputInstanceId={componentId}
|
||||
hasRightElement={isDefined(VariablePicker)}
|
||||
hoverable
|
||||
preventFocusStackUpdate
|
||||
>
|
||||
{triggerContent}
|
||||
<StyledIconButton>
|
||||
<IconChevronDown
|
||||
size={theme.icon.size.md}
|
||||
color={theme.font.color.light}
|
||||
/>
|
||||
</StyledIconButton>
|
||||
</FormFieldInputInnerContainer>
|
||||
</StyledFormSelectContainerWrapper>
|
||||
}
|
||||
dropdownComponents={
|
||||
<FormWorkspaceMemberFilterValueInputDropdownContent
|
||||
dropdownId={dropdownId}
|
||||
selectableListId={selectableListId}
|
||||
searchFilter={searchFilter}
|
||||
onSearchChange={handleSearchChange}
|
||||
pinnedSelectableItems={filteredPinnedSelectableItems}
|
||||
recordsToSelect={recordsToSelect}
|
||||
filteredSelectedRecords={filteredSelectedRecords}
|
||||
selectedRecords={selectedRecords}
|
||||
loading={loading}
|
||||
onSelectChange={handleSelectChange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{isDefined(VariablePicker) && !readonly && (
|
||||
<VariablePicker
|
||||
instanceId={variablesDropdownId}
|
||||
disabled={readonly}
|
||||
onVariableSelect={handleVariableTagInsert}
|
||||
shouldDisplayRecordObjects={true}
|
||||
shouldDisplayRecordFields={false}
|
||||
/>
|
||||
)}
|
||||
</FormFieldInputRowContainer>
|
||||
</FormFieldInputContainer>
|
||||
);
|
||||
};
|
||||
+64
@@ -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<HTMLInputElement>) => 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) => (
|
||||
<DropdownContent widthInPixels={GenericDropdownContentWidth.ExtraLarge}>
|
||||
<DropdownMenuSearchInput
|
||||
autoFocus
|
||||
type="text"
|
||||
value={searchFilter}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
{pinnedSelectableItems.length > 0 && (
|
||||
<>
|
||||
<ObjectFilterDropdownRecordPinnedItems
|
||||
selectableItems={pinnedSelectableItems}
|
||||
onChange={onSelectChange}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<MultipleSelectDropdown
|
||||
selectableListId={selectableListId}
|
||||
focusId={dropdownId}
|
||||
itemsToSelect={recordsToSelect}
|
||||
filteredSelectedItems={filteredSelectedRecords}
|
||||
selectedItems={selectedRecords}
|
||||
onChange={onSelectChange}
|
||||
searchFilter={searchFilter}
|
||||
loadingItems={loading}
|
||||
/>
|
||||
</DropdownContent>
|
||||
);
|
||||
+67
@@ -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 (
|
||||
<VariableChipStandalone
|
||||
rawVariableName={defaultValue ?? ''}
|
||||
onRemove={readonly ? undefined : onUnlinkVariable}
|
||||
isFullRecord
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefined(triggerDisplayText)) {
|
||||
return (
|
||||
<StyledTriggerLabel>
|
||||
{isCurrentWorkspaceMemberSelected && <IconUserCircle size={12} />}
|
||||
{triggerDisplayText}
|
||||
</StyledTriggerLabel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledPlaceholderContainer>
|
||||
<FormFieldPlaceholder>{t`Select`}</FormFieldPlaceholder>
|
||||
</StyledPlaceholderContainer>
|
||||
);
|
||||
};
|
||||
+56
@@ -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,
|
||||
});
|
||||
};
|
||||
+6
@@ -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<GroupByRawResult[]> {
|
||||
const currentWorkspaceMemberId = isUserAuthContext(authContext)
|
||||
? authContext.workspaceMemberId
|
||||
: undefined;
|
||||
|
||||
const gqlOperationFilter = convertChartFilterToGqlOperationFilter({
|
||||
filter,
|
||||
flatFieldMetadataMaps,
|
||||
userTimezone,
|
||||
currentWorkspaceMemberId,
|
||||
});
|
||||
|
||||
const primaryGroupByField = getFieldMetadata(
|
||||
|
||||
+95
@@ -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<FlatFieldMetadata>;
|
||||
|
||||
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({});
|
||||
});
|
||||
});
|
||||
+3
@@ -22,12 +22,14 @@ type ConvertChartFilterToGqlOperationFilterParams = {
|
||||
filter: ChartFilter | undefined;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user