mirror of
https://github.com/twentyhq/twenty.git
synced 2025-12-24 00:19:04 -05:00
Refactored Date to Temporal in critical date zones (#16544)
Fixes https://github.com/twentyhq/twenty/issues/16110 This PR implements Temporal to replace the legacy Date object, in all features that are time zone sensitive. (around 80% of the app) Here we define a few utils to handle Temporal primitives and obtain an easier DX for timezone manipulation, front end and back end. This PR deactivates the usage of timezone from the graph configuration, because for now it's always UTC and is not really relevant, let's handle that later. Workflows code and backend only code that don't take user input are using UTC time zone, the affected utils have not been refactored yet because this PR is big enough. # New way of filtering on date intervals As we'll progressively rollup Temporal everywhere in the codebase and remove `Date` JS object everywhere possible, we'll use the way to filter that is recommended by Temporal. This way of filtering on date intervals involves half-open intervals, and is the preferred way to avoid edge-cases with DST and smallest time increment edge-case. ## Filtering endOfX with DST edge-cases Some day-light save time shifts involve having no existing hour, or even day on certain days, for example Samoa Islands have no 30th of December 2011 : https://www.timeanddate.com/news/time/samoa-dateline.html, it jumps from 29th to 31st, so filtering on `< next period start` makes it easier to let the date library handle the strict inferior comparison, than filtering on `≤ end of period` and trying to compute manually the end of the period. For example for Samoa Islands, is end of day `2011-12-29T23:59:59.999` or is it `2011-12-30T23:59:59.999` ? If you say I don't need to know and compute it, because I want everything strictly before `2011-12-29T00:00:00 + start of next day (according to the library which knows those edge-cases)`, then you have a 100% deterministic way of computing date intervals in any timezone, for any day of any year. Of course the Samoa example is an extreme one, but more common ones involve DST shifts of 1 hour, which are still problematic on certain days of the year. ## Computing the exact _end of period_ Having an open interval filtering, with `[included - included]` instead of half-open `[included - excluded)`, forces to compute the open end of an interval, which often involves taking an arbitrary unit like minute, second, microsecond or nanosecond, which will lead to edge-case of unhandled values. For example, let's say my code computes endOfDay by setting the time to `23:59:59.999`, if another library, API, or anything else, ends up giving me a date-time with another time precision `23:59:59.999999999` (down to the nanosecond), then this date-time will be filtered out, while it should not. The good deterministic way to avoid 100% of those complex bugs is to create a half-open filter : `≥ start of period` to `< start of next period` For example : `≥ 2025-01-01T00:00:00` to `< 2025-01-02T00:00:00` instead of `≥ 2025-01-01T00:00:00` to `≤ 2025-01-01T23:59:59.999` Because, `2025-01-01T00:00:00` = `2025-01-01T00:00:00.000` = `2025-01-01T00:00:00.000000` = `2025-01-01T00:00:00.000000000` => no risk of error in computing start of period But `2025-01-01T23:59:59` ≠ `2025-01-01T23:59:59.999` ≠ `2025-01-01T23:59:59.999999` ≠ `2025-01-01T23:59:59.999999999` => existing risk of error in computing end of period This is why an half-open interval has no risk of error in computing a date-time interval filter. Here is a link to this debate : https://github.com/tc39/proposal-temporal/issues/2568 > For this reason, we recommend not calculating the exact nanosecond at the end of the day if it's not absolutely necessary. For example, if it's needed for <= comparisons, we recommend just changing the comparison code. So instead of <= zdtEndOfDay your code could be < zdtStartOfNextDay which is easier to calculate and not subject to the issue of not knowing which unit is the right one. > > [Justin Grant](https://github.com/justingrant), top contributor of Temporal ## Application to our codebase Applying this half-open filtering paradigm to our codebase means we would have to rename `IS_AFTER` to `IS_AFTER_OR_EQUAL` and to keep `IS_BEFORE` (or even `IS_STRICTLY_BEFORE`) to make this half-open interval self-explanatory everywhere in the codebase, this will avoid any confusion. See the relevant issue : https://github.com/twentyhq/core-team-issues/issues/2010 In the mean time, we'll keep this operand and add this semantic in the naming everywhere possible. ## Example with a different user timezone Example on a graph grouped by week in timezone Pacific/Samoa, on a computer running on Europe/Paris : <img width="342" height="511" alt="image" src="https://github.com/user-attachments/assets/9e7d5121-ecc4-4233-835b-f59293fbd8c8" /> Then the associated data in the table view, with our **half-open date-time filter** : <img width="804" height="262" alt="image" src="https://github.com/user-attachments/assets/28efe1d7-d2fc-4aec-b521-bada7f980447" /> And the associated SQL query result to see how DATE_TRUNC in Postgres applies its internal start of week logic : <img width="709" height="220" alt="image" src="https://github.com/user-attachments/assets/4d0542e1-eaae-4b4b-afa9-5005f48ffdca" /> The associated SQL query without parameters to test in your SQL client : ```SQL SELECT "opportunity"."closeDate" as "close_date", TO_CHAR(DATE_TRUNC('week', "opportunity"."closeDate", 'Pacific/Samoa') AT TIME ZONE 'Pacific/Samoa', 'YYYY-MM-DD') AS "DATE_TRUNC by week start in timezone Pacific/Samoa", "opportunity"."name" FROM "workspace_1wgvd1injqtife6y4rvfbu3h5"."opportunity" "opportunity" ORDER BY "opportunity"."closeDate" ASC NULLS LAST ``` # Date picker simplification (not in this PR) Our DatePicker component, which is wrapping `react-datepicker` library component, is now exposing plain dates as string instead of Date object. The Date object is still used internally to manage the library component, but since the date picker calendar is only manipulating plain dates, there is no need to add timezone management to it, and no need to expose a handleChange with Date object. The timezone management relies on date time inputs now. The modification has been made in a previous PR : https://github.com/twentyhq/twenty/issues/15377 but it's good to reference it here. # Calendar feature refactor Calendar feature has been refactored to rely on Temporal.PlainDate as much as possible, while leaving some date-fns utils to avoid re-coding them. Since the trick is to use utils to convert back and from Date object in exec env reliably, we can do it everywhere we need to interface legacy Date object utils and Temporal related code. ## TimeZone is now shown on Calendar : <img width="894" height="958" alt="image" src="https://github.com/user-attachments/assets/231f8107-fad6-4786-b532-456692c20f1d" /> ## Month picker has been refactored <img width="503" height="266" alt="image" src="https://github.com/user-attachments/assets/cb90bc34-6c4d-436d-93bc-4b6fb00de7f5" /> Since the days weren't useful, the picker has been refactored to remove the days. # Miscellaneous - Fixed a bug with drag and drop edge-case with 2 items in a list. # Improvements ## Lots of chained operations It would be nice to create small utils to avoid repeated chained operations, but that is how Temporal is designed, a very small set of primitive operations that allow to compose everything needed. Maybe we'll have wrappers on top of Temporal in the coming years. ## Creation of Temporal objects is throwing errors If the input is badly formatted Temporal will throw, we might want to adopt a global strategy to avoid that. Example : ```ts const newPlainDate = Temporal.PlainDate.from('bad-string'); // Will throw ```
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.7.17",
|
||||
"@date-fns/tz": "^1.4.1",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@floating-ui/react": "^0.24.3",
|
||||
@@ -54,6 +53,7 @@
|
||||
"semver": "^7.5.4",
|
||||
"slash": "^5.1.0",
|
||||
"storybook-addon-mock-date": "^0.6.0",
|
||||
"temporal-polyfill": "^0.3.0",
|
||||
"ts-key-enum": "^2.0.12",
|
||||
"tslib": "^2.8.1",
|
||||
"type-fest": "4.10.1",
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('computeContextStoreFilters', () => {
|
||||
|
||||
const mockFilterValueDependencies: RecordFilterValueDependencies = {
|
||||
currentWorkspaceMemberId: '32219445-f587-4c40-b2b1-6d3205ed96da',
|
||||
timeZone: 'Europe/Paris',
|
||||
};
|
||||
|
||||
it('should work for selection mode', () => {
|
||||
|
||||
@@ -2,11 +2,11 @@ import { renderHook } from '@testing-library/react';
|
||||
import { type ReactNode } from 'react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
||||
import { CalendarStartDay } from 'twenty-shared';
|
||||
import { DateFormat } from '@/localization/constants/DateFormat';
|
||||
import { TimeFormat } from '@/localization/constants/TimeFormat';
|
||||
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
|
||||
import { workspaceMemberFormatPreferencesState } from '@/localization/states/workspaceMemberFormatPreferencesState';
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
|
||||
const mockPreferences = {
|
||||
timeZone: 'America/New_York',
|
||||
|
||||
@@ -3,7 +3,6 @@ import { type ReactNode } from 'react';
|
||||
import { RecoilRoot, type MutableSnapshot } from 'recoil';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { CalendarStartDay } from 'twenty-shared';
|
||||
import { DateFormat } from '@/localization/constants/DateFormat';
|
||||
import { NumberFormat } from '@/localization/constants/NumberFormat';
|
||||
import { TimeFormat } from '@/localization/constants/TimeFormat';
|
||||
@@ -16,6 +15,8 @@ import { detectTimeFormat } from '@/localization/utils/detection/detectTimeForma
|
||||
import { detectTimeZone } from '@/localization/utils/detection/detectTimeZone';
|
||||
import { getWorkspaceMemberUpdateFromFormatPreferences } from '@/localization/utils/format-preferences/getWorkspaceMemberUpdateFromFormatPreferences';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
import { FirstDayOfTheWeek } from 'twenty-shared/types';
|
||||
|
||||
jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({
|
||||
useUpdateOneRecord: jest.fn(),
|
||||
@@ -93,7 +94,7 @@ describe('useFormatPreferences', () => {
|
||||
mockDetectDateFormat.mockReturnValue('MONTH_FIRST');
|
||||
mockDetectTimeFormat.mockReturnValue('HOUR_24');
|
||||
mockDetectNumberFormat.mockReturnValue('COMMAS_AND_DOT');
|
||||
mockDetectCalendarStartDay.mockReturnValue('MONDAY');
|
||||
mockDetectCalendarStartDay.mockReturnValue(FirstDayOfTheWeek.MONDAY);
|
||||
mockGetWorkspaceMemberUpdateFromFormatPreferences.mockReturnValue({});
|
||||
|
||||
mockUpdateOneRecord.mockResolvedValue({});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback } from 'react';
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { CalendarStartDay } from 'twenty-shared';
|
||||
import { DateFormat } from '@/localization/constants/DateFormat';
|
||||
import { NumberFormat } from '@/localization/constants/NumberFormat';
|
||||
import { TimeFormat } from '@/localization/constants/TimeFormat';
|
||||
@@ -19,6 +18,7 @@ import { getFormatPreferencesFromWorkspaceMember } from '@/localization/utils/fo
|
||||
import { getWorkspaceMemberUpdateFromFormatPreferences } from '@/localization/utils/format-preferences/getWorkspaceMemberUpdateFromFormatPreferences';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
import { logError } from '~/utils/logError';
|
||||
|
||||
export type FormatPreferenceKey = keyof WorkspaceMemberFormatPreferences;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CalendarStartDay } from 'twenty-shared';
|
||||
import { DateFormat } from '@/localization/constants/DateFormat';
|
||||
import { NumberFormat } from '@/localization/constants/NumberFormat';
|
||||
import { TimeFormat } from '@/localization/constants/TimeFormat';
|
||||
@@ -7,6 +6,7 @@ import { detectDateFormat } from '@/localization/utils/detection/detectDateForma
|
||||
import { detectNumberFormat } from '@/localization/utils/detection/detectNumberFormat';
|
||||
import { detectTimeFormat } from '@/localization/utils/detection/detectTimeFormat';
|
||||
import { detectTimeZone } from '@/localization/utils/detection/detectTimeZone';
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
|
||||
export type WorkspaceMemberFormatPreferences = {
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import { type CalendarStartDay } from 'twenty-shared';
|
||||
import { type ExcludeLiteral } from '~/types/ExcludeLiteral';
|
||||
import { FirstDayOfTheWeek } from 'twenty-shared/types';
|
||||
|
||||
const MONDAY_KEY: keyof typeof CalendarStartDay = 'MONDAY';
|
||||
const SATURDAY_KEY: keyof typeof CalendarStartDay = 'SATURDAY';
|
||||
const SUNDAY_KEY: keyof typeof CalendarStartDay = 'SUNDAY';
|
||||
|
||||
export type NonSystemCalendarStartDay = ExcludeLiteral<
|
||||
keyof typeof CalendarStartDay,
|
||||
'SYSTEM'
|
||||
>;
|
||||
|
||||
export const detectCalendarStartDay = (): NonSystemCalendarStartDay => {
|
||||
export const detectCalendarStartDay = (): FirstDayOfTheWeek => {
|
||||
// Use Intl.Locale to get the first day of the week from the user's locale
|
||||
// This requires a modern browser that supports Intl.Locale
|
||||
try {
|
||||
@@ -30,12 +20,12 @@ export const detectCalendarStartDay = (): NonSystemCalendarStartDay => {
|
||||
// Intl.Locale uses 1=Monday, 7=Sunday, 6=Saturday
|
||||
switch (firstDay) {
|
||||
case 1:
|
||||
return MONDAY_KEY;
|
||||
return FirstDayOfTheWeek.MONDAY;
|
||||
case 6:
|
||||
return SATURDAY_KEY;
|
||||
return FirstDayOfTheWeek.SATURDAY;
|
||||
case 7:
|
||||
default:
|
||||
return SUNDAY_KEY;
|
||||
return FirstDayOfTheWeek.SUNDAY;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -63,7 +53,7 @@ export const detectCalendarStartDay = (): NonSystemCalendarStartDay => {
|
||||
language.startsWith('en-au') || // Australian English
|
||||
language.startsWith('en-nz') // New Zealand English
|
||||
) {
|
||||
return MONDAY_KEY;
|
||||
return FirstDayOfTheWeek.MONDAY;
|
||||
}
|
||||
|
||||
// Middle Eastern countries often start with Saturday
|
||||
@@ -72,9 +62,9 @@ export const detectCalendarStartDay = (): NonSystemCalendarStartDay => {
|
||||
language.startsWith('he') || // Hebrew
|
||||
language.startsWith('fa') // Persian
|
||||
) {
|
||||
return SATURDAY_KEY;
|
||||
return FirstDayOfTheWeek.SATURDAY;
|
||||
}
|
||||
|
||||
// Default to Sunday (US, Canada, Japan, etc.)
|
||||
return SUNDAY_KEY;
|
||||
return FirstDayOfTheWeek.SUNDAY;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type TimeFormat } from '@/localization/constants/TimeFormat';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const detectTimeFormat = (): keyof typeof TimeFormat => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { CalendarStartDay } from 'twenty-shared';
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
|
||||
import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay';
|
||||
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
|
||||
|
||||
@@ -16,7 +16,7 @@ type AdvancedFilterCommandMenuRecordFilterOperandSelectProps = {
|
||||
export const AdvancedFilterCommandMenuRecordFilterOperandSelect = ({
|
||||
recordFilterId,
|
||||
}: AdvancedFilterCommandMenuRecordFilterOperandSelectProps) => {
|
||||
const { readonly } = useContext(AdvancedFilterContext);
|
||||
const { readonly, isWorkflowFindRecords } = useContext(AdvancedFilterContext);
|
||||
const currentRecordFilters = useRecoilComponentValue(
|
||||
currentRecordFiltersComponentState,
|
||||
);
|
||||
@@ -36,12 +36,15 @@ export const AdvancedFilterCommandMenuRecordFilterOperandSelect = ({
|
||||
})
|
||||
: [];
|
||||
|
||||
const shouldUseUTCTimeZone = isWorkflowFindRecords === true;
|
||||
const timeZoneAbbreviation = shouldUseUTCTimeZone ? 'UTC' : undefined;
|
||||
|
||||
if (isDisabled === true) {
|
||||
return (
|
||||
<SelectControl
|
||||
selectedOption={{
|
||||
label: filter?.operand
|
||||
? getOperandLabel(filter.operand)
|
||||
? getOperandLabel(filter.operand, timeZoneAbbreviation)
|
||||
: t`Select operand`,
|
||||
value: null,
|
||||
}}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { currentRecordFiltersComponentState } from '@/object-record/record-filte
|
||||
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter';
|
||||
import { WORKFLOW_TIMEZONE } from '@/workflow/constants/WorkflowTimeZone';
|
||||
import { isObject, isString } from '@sniptt/guards';
|
||||
import { useContext } from 'react';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
@@ -32,9 +33,12 @@ export const AdvancedFilterCommandMenuValueFormInput = ({
|
||||
}: {
|
||||
recordFilterId: string;
|
||||
}) => {
|
||||
const { readonly, VariablePicker, objectMetadataItem } = useContext(
|
||||
AdvancedFilterContext,
|
||||
);
|
||||
const {
|
||||
readonly,
|
||||
VariablePicker,
|
||||
objectMetadataItem,
|
||||
isWorkflowFindRecords,
|
||||
} = useContext(AdvancedFilterContext);
|
||||
|
||||
const currentRecordFilters = useRecoilComponentValue(
|
||||
currentRecordFiltersComponentState,
|
||||
@@ -179,6 +183,9 @@ export const AdvancedFilterCommandMenuValueFormInput = ({
|
||||
metadata: fieldDefinition?.metadata as FieldMetadata,
|
||||
};
|
||||
|
||||
const shouldUseUTCTimeZone = isWorkflowFindRecords === true;
|
||||
const timeZone = shouldUseUTCTimeZone ? WORKFLOW_TIMEZONE : undefined;
|
||||
|
||||
return (
|
||||
<FormFieldInput
|
||||
field={field}
|
||||
@@ -187,6 +194,7 @@ export const AdvancedFilterCommandMenuValueFormInput = ({
|
||||
readonly={readonly}
|
||||
// VariablePicker is not supported for date filters yet
|
||||
VariablePicker={isFilterableByDateValue ? undefined : VariablePicker}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { AdvancedFilterRecordFilterOperandSelectContent } from '@/object-record/advanced-filter/components/AdvancedFilterRecordFilterOperandSelectContent';
|
||||
import { getOperandLabel } from '@/object-record/object-filter-dropdown/utils/getOperandLabel';
|
||||
import { useTimeZoneAbbreviationForNowInUserTimeZone } from '@/object-record/record-filter/hooks/useTimeZoneAbbreviationForNowInUserTimeZone';
|
||||
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
|
||||
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { SelectControl } from '@/ui/input/components/SelectControl';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import styled from '@emotion/styled';
|
||||
@@ -38,12 +40,21 @@ export const AdvancedFilterRecordFilterOperandSelect = ({
|
||||
})
|
||||
: [];
|
||||
|
||||
const { userTimeZoneAbbreviation } =
|
||||
useTimeZoneAbbreviationForNowInUserTimeZone();
|
||||
|
||||
const { isSystemTimezone } = useUserTimezone();
|
||||
|
||||
const timeZoneAbbreviation = !isSystemTimezone
|
||||
? userTimeZoneAbbreviation
|
||||
: null;
|
||||
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<SelectControl
|
||||
selectedOption={{
|
||||
label: filter?.operand
|
||||
? getOperandLabel(filter.operand)
|
||||
? getOperandLabel(filter.operand, timeZoneAbbreviation)
|
||||
: t`Select operand`,
|
||||
value: null,
|
||||
}}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { DEFAULT_ADVANCED_FILTER_DROPDOWN_OFFSET } from '@/object-record/advanced-filter/constants/DefaultAdvancedFilterDropdownOffset';
|
||||
import { AdvancedFilterContext } from '@/object-record/advanced-filter/states/context/AdvancedFilterContext';
|
||||
import { useApplyObjectFilterDropdownOperand } from '@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand';
|
||||
|
||||
import { getOperandLabel } from '@/object-record/object-filter-dropdown/utils/getOperandLabel';
|
||||
import { useTimeZoneAbbreviationForNowInUserTimeZone } from '@/object-record/record-filter/hooks/useTimeZoneAbbreviationForNowInUserTimeZone';
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { type RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { SelectControl } from '@/ui/input/components/SelectControl';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
@@ -15,6 +18,7 @@ import { SelectableListItem } from '@/ui/layout/selectable-list/components/Selec
|
||||
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useContext } from 'react';
|
||||
import { type ViewFilterOperand } from 'twenty-shared/types';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
|
||||
@@ -31,6 +35,8 @@ export const AdvancedFilterRecordFilterOperandSelectContent = ({
|
||||
}: AdvancedFilterRecordFilterOperandSelectContentProps) => {
|
||||
const dropdownId = `advanced-filter-view-filter-operand-${recordFilterId}`;
|
||||
|
||||
const { isWorkflowFindRecords } = useContext(AdvancedFilterContext);
|
||||
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
|
||||
const { applyObjectFilterDropdownOperand } =
|
||||
@@ -47,6 +53,18 @@ export const AdvancedFilterRecordFilterOperandSelectContent = ({
|
||||
dropdownId,
|
||||
);
|
||||
|
||||
const { userTimeZoneAbbreviation } =
|
||||
useTimeZoneAbbreviationForNowInUserTimeZone();
|
||||
|
||||
const { isSystemTimezone } = useUserTimezone();
|
||||
|
||||
const timeZoneAbbreviation =
|
||||
isWorkflowFindRecords === true
|
||||
? 'UTC'
|
||||
: !isSystemTimezone
|
||||
? userTimeZoneAbbreviation
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
@@ -54,7 +72,7 @@ export const AdvancedFilterRecordFilterOperandSelectContent = ({
|
||||
<SelectControl
|
||||
selectedOption={{
|
||||
label: filter?.operand
|
||||
? getOperandLabel(filter.operand)
|
||||
? getOperandLabel(filter.operand, timeZoneAbbreviation)
|
||||
: t`Select operand`,
|
||||
value: null,
|
||||
}}
|
||||
@@ -83,7 +101,7 @@ export const AdvancedFilterRecordFilterOperandSelectContent = ({
|
||||
onClick={() => {
|
||||
handleOperandChange(filterOperand);
|
||||
}}
|
||||
text={getOperandLabel(filterOperand)}
|
||||
text={getOperandLabel(filterOperand, timeZoneAbbreviation)}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { CalendarStartDay } from 'twenty-shared';
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
|
||||
import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay';
|
||||
import { useApplyObjectFilterDropdownFilterValue } from '@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownFilterValue';
|
||||
@@ -40,6 +40,7 @@ export const ObjectFilterDropdownDateInput = () => {
|
||||
const handleAbsoluteDateChange = (newPlainDate: string | null) => {
|
||||
const newFilterValue = newPlainDate ?? '';
|
||||
|
||||
// TODO: remove this and use getDisplayValue instead
|
||||
const formattedDate = formatDateString({
|
||||
value: newPlainDate,
|
||||
timeZone,
|
||||
@@ -109,7 +110,7 @@ export const ObjectFilterDropdownDateInput = () => {
|
||||
instanceId={`object-filter-dropdown-date-input`}
|
||||
relativeDate={relativeDate}
|
||||
isRelative={isRelativeOperand}
|
||||
date={plainDateValue ?? null}
|
||||
plainDateString={plainDateValue ?? null}
|
||||
onChange={handleAbsoluteDateChange}
|
||||
onRelativeDateChange={handleRelativeDateChange}
|
||||
onClear={handleClear}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { CalendarStartDay } from 'twenty-shared';
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
|
||||
import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay';
|
||||
import { useApplyObjectFilterDropdownFilterValue } from '@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownFilterValue';
|
||||
@@ -9,7 +9,7 @@ import { DateTimePicker } from '@/ui/input/components/internal/date/components/D
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { UserContext } from '@/users/contexts/UserContext';
|
||||
import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter';
|
||||
import { useContext, useState } from 'react';
|
||||
import { useContext } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { ViewFilterOperand, type FirstDayOfTheWeek } from 'twenty-shared/types';
|
||||
import {
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
type RelativeDateFilter,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
import { formatDateTimeString } from '~/utils/string/formatDateTimeString';
|
||||
|
||||
@@ -26,6 +29,8 @@ export const ObjectFilterDropdownDateTimeInput = () => {
|
||||
const dateLocale = useRecoilValue(dateLocaleState);
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const objectFilterDropdownCurrentRecordFilter = useRecoilComponentValue(
|
||||
objectFilterDropdownCurrentRecordFilterComponentState,
|
||||
);
|
||||
@@ -33,21 +38,11 @@ export const ObjectFilterDropdownDateTimeInput = () => {
|
||||
const { applyObjectFilterDropdownFilterValue } =
|
||||
useApplyObjectFilterDropdownFilterValue();
|
||||
|
||||
const initialFilterValue = isDefined(objectFilterDropdownCurrentRecordFilter)
|
||||
? resolveDateTimeFilter(objectFilterDropdownCurrentRecordFilter)
|
||||
: null;
|
||||
|
||||
const [internalDate, setInternalDate] = useState<Date | null>(
|
||||
initialFilterValue instanceof Date ? initialFilterValue : null,
|
||||
);
|
||||
|
||||
const handleAbsoluteDateChange = (newDate: Date | null) => {
|
||||
setInternalDate(newDate);
|
||||
|
||||
const newFilterValue = newDate?.toISOString() ?? '';
|
||||
const handleAbsoluteDateChange = (newDate: Temporal.ZonedDateTime | null) => {
|
||||
const newFilterValue = newDate?.toInstant().toString() ?? '';
|
||||
|
||||
const formattedDateTime = formatDateTimeString({
|
||||
value: newDate?.toISOString(),
|
||||
value: newFilterValue,
|
||||
timeZone,
|
||||
dateFormat,
|
||||
timeFormat,
|
||||
@@ -89,33 +84,39 @@ export const ObjectFilterDropdownDateTimeInput = () => {
|
||||
applyObjectFilterDropdownFilterValue(newFilterValue, newDisplayValue);
|
||||
};
|
||||
|
||||
const isRelativeOperand =
|
||||
objectFilterDropdownCurrentRecordFilter?.operand ===
|
||||
ViewFilterOperand.IS_RELATIVE;
|
||||
|
||||
const handleClear = () => {
|
||||
isRelativeOperand
|
||||
? handleRelativeDateChange(null)
|
||||
: handleAbsoluteDateChange(null);
|
||||
};
|
||||
const resolvedValue = objectFilterDropdownCurrentRecordFilter
|
||||
? resolveDateTimeFilter(objectFilterDropdownCurrentRecordFilter)
|
||||
: null;
|
||||
|
||||
const relativeDate =
|
||||
resolvedValue && !(resolvedValue instanceof Date)
|
||||
const isRelativeDateFilter =
|
||||
objectFilterDropdownCurrentRecordFilter?.operand ===
|
||||
ViewFilterOperand.IS_RELATIVE &&
|
||||
isDefined(resolvedValue) &&
|
||||
typeof resolvedValue === 'object';
|
||||
|
||||
const relativeDate = isRelativeDateFilter ? resolvedValue : undefined;
|
||||
const stringFilterValue =
|
||||
objectFilterDropdownCurrentRecordFilter?.operand !==
|
||||
ViewFilterOperand.IS_RELATIVE && typeof resolvedValue === 'string'
|
||||
? resolvedValue
|
||||
: undefined;
|
||||
|
||||
const internalZonedDateTime =
|
||||
!isRelativeDateFilter && isNonEmptyString(stringFilterValue)
|
||||
? Temporal.Instant.from(stringFilterValue).toZonedDateTimeISO(
|
||||
timeZone ?? userTimezone,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<DateTimePicker
|
||||
instanceId={`object-filter-dropdown-date-time-input`}
|
||||
relativeDate={relativeDate}
|
||||
isRelative={isRelativeOperand}
|
||||
date={internalDate}
|
||||
isRelative={isRelativeDateFilter}
|
||||
date={internalZonedDateTime}
|
||||
onChange={handleAbsoluteDateChange}
|
||||
onRelativeDateChange={handleRelativeDateChange}
|
||||
onClear={handleClear}
|
||||
clearable={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,8 +5,10 @@ import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-recor
|
||||
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
|
||||
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
|
||||
import { getOperandLabel } from '@/object-record/object-filter-dropdown/utils/getOperandLabel';
|
||||
import { useTimeZoneAbbreviationForNowInUserTimeZone } from '@/object-record/record-filter/hooks/useTimeZoneAbbreviationForNowInUserTimeZone';
|
||||
import { type RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
|
||||
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { DropdownMenuInnerSelect } from '@/ui/layout/dropdown/components/DropdownMenuInnerSelect';
|
||||
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
@@ -38,8 +40,17 @@ export const ObjectFilterDropdownInnerSelectOperandDropdown = () => {
|
||||
})
|
||||
: [];
|
||||
|
||||
const { userTimeZoneAbbreviation } =
|
||||
useTimeZoneAbbreviationForNowInUserTimeZone();
|
||||
|
||||
const { isSystemTimezone } = useUserTimezone();
|
||||
|
||||
const timeZoneAbbreviation = !isSystemTimezone
|
||||
? userTimeZoneAbbreviation
|
||||
: null;
|
||||
|
||||
const options = operandsForFilterType.map((operand) => ({
|
||||
label: getOperandLabel(operand),
|
||||
label: getOperandLabel(operand, timeZoneAbbreviation),
|
||||
value: operand,
|
||||
})) as SelectOption[];
|
||||
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
import { DATE_OPERANDS_THAT_SHOULD_BE_INITIALIZED_WITH_NOW } from '@/object-record/object-filter-dropdown/constants/DateOperandsThatShouldBeInitializedWithNow';
|
||||
import { useGetInitialFilterValue } from '@/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue';
|
||||
import { useUpsertObjectFilterDropdownCurrentFilter } from '@/object-record/object-filter-dropdown/hooks/useUpsertObjectFilterDropdownCurrentFilter';
|
||||
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
|
||||
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
|
||||
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
|
||||
import { getRelativeDateDisplayValue } from '@/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue';
|
||||
import { useCreateEmptyRecordFilterFromFieldMetadataItem } from '@/object-record/record-filter/hooks/useCreateEmptyRecordFilterFromFieldMetadataItem';
|
||||
import { useGetRelativeDateFilterWithUserTimezone } from '@/object-record/record-filter/hooks/useGetRelativeDateFilterWithUserTimezone';
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
|
||||
import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from 'twenty-shared/constants';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
isDefined,
|
||||
relativeDateFilterStringifiedSchema,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
export const useApplyObjectFilterDropdownOperand = () => {
|
||||
const { userTimezone } = useUserTimezone();
|
||||
const objectFilterDropdownCurrentRecordFilter = useRecoilComponentValue(
|
||||
objectFilterDropdownCurrentRecordFilterComponentState,
|
||||
);
|
||||
@@ -40,8 +44,6 @@ export const useApplyObjectFilterDropdownOperand = () => {
|
||||
const { createEmptyRecordFilterFromFieldMetadataItem } =
|
||||
useCreateEmptyRecordFilterFromFieldMetadataItem();
|
||||
|
||||
const { getInitialFilterValue } = useGetInitialFilterValue();
|
||||
|
||||
const { getRelativeDateFilterWithUserTimezone } =
|
||||
useGetRelativeDateFilterWithUserTimezone();
|
||||
|
||||
@@ -86,24 +88,7 @@ export const useApplyObjectFilterDropdownOperand = () => {
|
||||
(recordFilterToUpsert.type === 'DATE' ||
|
||||
recordFilterToUpsert.type === 'DATE_TIME')
|
||||
) {
|
||||
if (
|
||||
DATE_OPERANDS_THAT_SHOULD_BE_INITIALIZED_WITH_NOW.includes(newOperand)
|
||||
) {
|
||||
// TODO: allow to keep same value when switching between is after, is before, is and is not
|
||||
// For now we reset with now each time we switch operand
|
||||
|
||||
const dateToUseAsISOString = new Date().toISOString();
|
||||
|
||||
const { displayValue, value } = getInitialFilterValue(
|
||||
recordFilterToUpsert.type,
|
||||
newOperand,
|
||||
dateToUseAsISOString,
|
||||
);
|
||||
|
||||
recordFilterToUpsert.value = value;
|
||||
|
||||
recordFilterToUpsert.displayValue = displayValue;
|
||||
} else if (newOperand === RecordFilterOperand.IS_RELATIVE) {
|
||||
if (newOperand === RecordFilterOperand.IS_RELATIVE) {
|
||||
const newRelativeDateFilter = getRelativeDateFilterWithUserTimezone(
|
||||
DEFAULT_RELATIVE_DATE_FILTER_VALUE,
|
||||
);
|
||||
@@ -111,13 +96,33 @@ export const useApplyObjectFilterDropdownOperand = () => {
|
||||
recordFilterToUpsert.value = stringifyRelativeDateFilter(
|
||||
newRelativeDateFilter,
|
||||
);
|
||||
|
||||
recordFilterToUpsert.displayValue = getRelativeDateDisplayValue(
|
||||
newRelativeDateFilter,
|
||||
);
|
||||
} else {
|
||||
recordFilterToUpsert.value = '';
|
||||
recordFilterToUpsert.displayValue = '';
|
||||
const filterValueIsEmpty = !isNonEmptyString(
|
||||
recordFilterToUpsert.value,
|
||||
);
|
||||
|
||||
const isStillRelativeFilterValue =
|
||||
relativeDateFilterStringifiedSchema.safeParse(
|
||||
recordFilterToUpsert.value,
|
||||
);
|
||||
|
||||
if (filterValueIsEmpty || isStillRelativeFilterValue.success) {
|
||||
const zonedDateToUse = Temporal.Now.zonedDateTimeISO(userTimezone);
|
||||
|
||||
if (recordFilterToUpsert.type === 'DATE') {
|
||||
const initialNowDateFilterValue = zonedDateToUse
|
||||
.toPlainDate()
|
||||
.toString();
|
||||
|
||||
recordFilterToUpsert.value = initialNowDateFilterValue;
|
||||
} else {
|
||||
const initialNowDateTimeFilterValue = zonedDateToUse
|
||||
.toInstant()
|
||||
.toString();
|
||||
|
||||
recordFilterToUpsert.value = initialNowDateTimeFilterValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useUserDateFormat } from '@/ui/input/components/internal/date/hooks/useUserDateFormat';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import { formatZonedDateTimeDatePart } from '~/utils/dates/formatZonedDateTimeDatePart';
|
||||
|
||||
export const useGetDateFilterDisplayValue = () => {
|
||||
const { userDateFormat } = useUserDateFormat();
|
||||
|
||||
const getDateFilterDisplayValue = (zonedDateTime: Temporal.ZonedDateTime) => {
|
||||
const displayValue = `${formatZonedDateTimeDatePart(zonedDateTime, userDateFormat)}`;
|
||||
|
||||
return { displayValue };
|
||||
};
|
||||
|
||||
return {
|
||||
getDateFilterDisplayValue,
|
||||
};
|
||||
};
|
||||
@@ -1,46 +1,27 @@
|
||||
import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/format-preferences/getDateFormatFromWorkspaceDateFormat';
|
||||
import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/format-preferences/getTimeFormatFromWorkspaceTimeFormat';
|
||||
import { useUserDateFormat } from '@/ui/input/components/internal/date/hooks/useUserDateFormat';
|
||||
import { useUserTimeFormat } from '@/ui/input/components/internal/date/hooks/useUserTimeFormat';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { format } from 'date-fns';
|
||||
import { shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone } from 'twenty-shared/utils';
|
||||
import { getTimezoneAbbreviationForZonedDateTime } from '@/ui/input/components/internal/date/utils/getTimeZoneAbbreviationForZonedDateTime';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import { formatZonedDateTimeDatePart } from '~/utils/dates/formatZonedDateTimeDatePart';
|
||||
import { formatZonedDateTimeTimePart } from '~/utils/dates/formatZonedDateTimeTimePart';
|
||||
|
||||
export const useGetDateTimeFilterDisplayValue = () => {
|
||||
const {
|
||||
userTimezone,
|
||||
isSystemTimezone,
|
||||
getTimezoneAbbreviationForPointInTime,
|
||||
} = useUserTimezone();
|
||||
const { isSystemTimezone } = useUserTimezone();
|
||||
|
||||
const { userDateFormat } = useUserDateFormat();
|
||||
const { userTimeFormat } = useUserTimeFormat();
|
||||
|
||||
const getDateTimeFilterDisplayValue = (correctPointInTime: Date) => {
|
||||
const shiftedDate =
|
||||
shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone(
|
||||
correctPointInTime,
|
||||
userTimezone,
|
||||
'sub',
|
||||
);
|
||||
|
||||
const dateFormatString =
|
||||
getDateFormatFromWorkspaceDateFormat(userDateFormat);
|
||||
|
||||
const timeFormatString =
|
||||
getTimeFormatFromWorkspaceTimeFormat(userTimeFormat);
|
||||
|
||||
const formatToUse = `${dateFormatString} ${timeFormatString}`;
|
||||
|
||||
const getDateTimeFilterDisplayValue = (
|
||||
referenceZonedDateTime: Temporal.ZonedDateTime,
|
||||
) => {
|
||||
const timezoneSuffix = !isSystemTimezone
|
||||
? ` (${getTimezoneAbbreviationForPointInTime(shiftedDate)})`
|
||||
? ` (${getTimezoneAbbreviationForZonedDateTime(referenceZonedDateTime)})`
|
||||
: '';
|
||||
|
||||
const displayValue = `${format(shiftedDate, formatToUse)}${timezoneSuffix}`;
|
||||
const displayValue = `${formatZonedDateTimeDatePart(referenceZonedDateTime, userDateFormat)} ${formatZonedDateTimeTimePart(referenceZonedDateTime, userTimeFormat)}${timezoneSuffix}`;
|
||||
|
||||
return {
|
||||
correctPointInTime,
|
||||
displayValue,
|
||||
};
|
||||
return { displayValue };
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/format-preferences/getDateFormatFromWorkspaceDateFormat';
|
||||
import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/format-preferences/getTimeFormatFromWorkspaceTimeFormat';
|
||||
import { useGetDateFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateFilterDisplayValue';
|
||||
import { useGetDateTimeFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue';
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
|
||||
import { useUserDateFormat } from '@/ui/input/components/internal/date/hooks/useUserDateFormat';
|
||||
import { useUserTimeFormat } from '@/ui/input/components/internal/date/hooks/useUserTimeFormat';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { TZDate } from '@date-fns/tz';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { format } from 'date-fns';
|
||||
import { DATE_TYPE_FORMAT } from 'twenty-shared/constants';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { type FilterableAndTSVectorFieldType } from 'twenty-shared/types';
|
||||
|
||||
const activeDatePickerOperands = [
|
||||
@@ -18,41 +13,25 @@ const activeDatePickerOperands = [
|
||||
];
|
||||
|
||||
export const useGetInitialFilterValue = () => {
|
||||
const {
|
||||
userTimezone,
|
||||
isSystemTimezone,
|
||||
getTimezoneAbbreviationForPointInTime,
|
||||
} = useUserTimezone();
|
||||
const { userDateFormat } = useUserDateFormat();
|
||||
const { userTimeFormat } = useUserTimeFormat();
|
||||
const { userTimezone } = useUserTimezone();
|
||||
const { getDateFilterDisplayValue } = useGetDateFilterDisplayValue();
|
||||
const { getDateTimeFilterDisplayValue } = useGetDateTimeFilterDisplayValue();
|
||||
|
||||
const getInitialFilterValue = (
|
||||
newType: FilterableAndTSVectorFieldType,
|
||||
newOperand: RecordFilterOperand,
|
||||
alreadyExistingISODate?: string,
|
||||
alreadyExistingZonedDateTime?: Temporal.ZonedDateTime,
|
||||
): Pick<RecordFilter, 'value' | 'displayValue'> | Record<string, never> => {
|
||||
switch (newType) {
|
||||
case 'DATE': {
|
||||
if (activeDatePickerOperands.includes(newOperand)) {
|
||||
const referenceDate = isNonEmptyString(alreadyExistingISODate)
|
||||
? new Date(alreadyExistingISODate)
|
||||
: new Date();
|
||||
const referenceDate =
|
||||
alreadyExistingZonedDateTime ??
|
||||
Temporal.Now.zonedDateTimeISO(userTimezone);
|
||||
|
||||
const shiftedDate = new TZDate(
|
||||
referenceDate.getFullYear(),
|
||||
referenceDate.getMonth(),
|
||||
referenceDate.getDate(),
|
||||
userTimezone,
|
||||
);
|
||||
const value = referenceDate.toPlainDate().toString();
|
||||
|
||||
const dateFormatString =
|
||||
getDateFormatFromWorkspaceDateFormat(userDateFormat);
|
||||
|
||||
shiftedDate.setSeconds(0);
|
||||
shiftedDate.setMilliseconds(0);
|
||||
|
||||
const value = format(shiftedDate, DATE_TYPE_FORMAT);
|
||||
const displayValue = format(shiftedDate, dateFormatString);
|
||||
const { displayValue } = getDateFilterDisplayValue(referenceDate);
|
||||
|
||||
return { value, displayValue };
|
||||
}
|
||||
@@ -61,36 +40,13 @@ export const useGetInitialFilterValue = () => {
|
||||
}
|
||||
case 'DATE_TIME': {
|
||||
if (activeDatePickerOperands.includes(newOperand)) {
|
||||
const referenceDate = isNonEmptyString(alreadyExistingISODate)
|
||||
? new Date(alreadyExistingISODate)
|
||||
: new Date();
|
||||
const referenceDate =
|
||||
alreadyExistingZonedDateTime ??
|
||||
Temporal.Now.zonedDateTimeISO(userTimezone);
|
||||
|
||||
const shiftedDate = new TZDate(
|
||||
referenceDate.getFullYear(),
|
||||
referenceDate.getMonth(),
|
||||
referenceDate.getDate(),
|
||||
referenceDate.getHours(),
|
||||
referenceDate.getMinutes(),
|
||||
userTimezone,
|
||||
);
|
||||
const value = referenceDate.toInstant().toString();
|
||||
|
||||
shiftedDate.setSeconds(0);
|
||||
shiftedDate.setMilliseconds(0);
|
||||
|
||||
const dateFormatString =
|
||||
getDateFormatFromWorkspaceDateFormat(userDateFormat);
|
||||
|
||||
const timeFormatString =
|
||||
getTimeFormatFromWorkspaceTimeFormat(userTimeFormat);
|
||||
|
||||
const formatToUse = `${dateFormatString} ${timeFormatString}`;
|
||||
|
||||
const timezoneSuffix = !isSystemTimezone
|
||||
? ` (${getTimezoneAbbreviationForPointInTime(shiftedDate)})`
|
||||
: '';
|
||||
|
||||
const value = shiftedDate.toISOString();
|
||||
const displayValue = `${format(shiftedDate, formatToUse)}${timezoneSuffix}`;
|
||||
const { displayValue } = getDateTimeFilterDisplayValue(referenceDate);
|
||||
|
||||
return { value, displayValue };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
|
||||
export const getOperandLabel = (
|
||||
operand: ViewFilterOperand | null | undefined,
|
||||
timeZoneAbbreviation?: string | null | undefined,
|
||||
) => {
|
||||
const shouldDisplayTimeZoneAbbreviation =
|
||||
isNonEmptyString(timeZoneAbbreviation);
|
||||
|
||||
const timeZoneAbbreviationSuffix = shouldDisplayTimeZoneAbbreviation
|
||||
? ` (${timeZoneAbbreviation})`
|
||||
: '';
|
||||
|
||||
switch (operand) {
|
||||
case ViewFilterOperand.CONTAINS:
|
||||
return t`Contains`;
|
||||
@@ -16,7 +25,7 @@ export const getOperandLabel = (
|
||||
case ViewFilterOperand.IS_BEFORE:
|
||||
return t`Is before`;
|
||||
case ViewFilterOperand.IS_AFTER:
|
||||
return t`Is after`;
|
||||
return t`Is after or equal`;
|
||||
case ViewFilterOperand.IS:
|
||||
return t`Is`;
|
||||
case ViewFilterOperand.IS_NOT:
|
||||
@@ -34,7 +43,7 @@ export const getOperandLabel = (
|
||||
case ViewFilterOperand.IS_IN_FUTURE:
|
||||
return t`Is in future`;
|
||||
case ViewFilterOperand.IS_TODAY:
|
||||
return t`Is today`;
|
||||
return t`Is today${timeZoneAbbreviationSuffix}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -42,7 +51,15 @@ export const getOperandLabel = (
|
||||
|
||||
export const getOperandLabelShort = (
|
||||
operand: ViewFilterOperand | null | undefined,
|
||||
timeZoneAbbreviation?: string | null | undefined,
|
||||
) => {
|
||||
const shouldDisplayTimeZoneAbbreviation =
|
||||
isNonEmptyString(timeZoneAbbreviation);
|
||||
|
||||
const timeZoneAbbreviationSuffix = shouldDisplayTimeZoneAbbreviation
|
||||
? ` (${timeZoneAbbreviation})`
|
||||
: '';
|
||||
|
||||
switch (operand) {
|
||||
case ViewFilterOperand.IS:
|
||||
case ViewFilterOperand.CONTAINS:
|
||||
@@ -63,13 +80,13 @@ export const getOperandLabelShort = (
|
||||
case ViewFilterOperand.IS_BEFORE:
|
||||
return '\u00A0< ';
|
||||
case ViewFilterOperand.IS_AFTER:
|
||||
return '\u00A0> ';
|
||||
return '\u00A0≥ ';
|
||||
case ViewFilterOperand.IS_IN_PAST:
|
||||
return t`: Past`;
|
||||
case ViewFilterOperand.IS_IN_FUTURE:
|
||||
return t`: Future`;
|
||||
case ViewFilterOperand.IS_TODAY:
|
||||
return t`: Today`;
|
||||
return t`: Today${timeZoneAbbreviationSuffix}`;
|
||||
default:
|
||||
return ': ';
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { getRelativeDateFilterTimeZoneAbbreviation } from '@/object-record/object-filter-dropdown/utils/getRelativeDateFilterTimeZoneAbbreviation';
|
||||
import { plural } from 'pluralize';
|
||||
|
||||
import { capitalize, type RelativeDateFilter } from 'twenty-shared/utils';
|
||||
import {
|
||||
capitalize,
|
||||
isDefined,
|
||||
type RelativeDateFilter,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
export const getRelativeDateDisplayValue = (
|
||||
relativeDate: RelativeDateFilter | null,
|
||||
relativeDate: RelativeDateFilter,
|
||||
shouldDisplayTimeZoneAbbreviation?: boolean,
|
||||
) => {
|
||||
if (!relativeDate) return '';
|
||||
const { direction, amount, unit } = relativeDate;
|
||||
|
||||
const directionStr = capitalize(direction.toLowerCase());
|
||||
const amountStr = direction === 'THIS' ? '' : amount;
|
||||
const unitStr =
|
||||
const directionFormatted = capitalize(direction.toLowerCase());
|
||||
const amountFormatted = direction === 'THIS' ? '' : amount;
|
||||
let unitFormatted =
|
||||
direction === 'THIS'
|
||||
? unit.toLowerCase()
|
||||
: amount
|
||||
@@ -19,7 +25,14 @@ export const getRelativeDateDisplayValue = (
|
||||
: unit.toLowerCase()
|
||||
: undefined;
|
||||
|
||||
return [directionStr, amountStr, unitStr]
|
||||
if (isDefined(relativeDate.timezone)) {
|
||||
const timeZoneAbbreviation =
|
||||
getRelativeDateFilterTimeZoneAbbreviation(relativeDate);
|
||||
|
||||
unitFormatted = `${unitFormatted ?? ''} ${shouldDisplayTimeZoneAbbreviation ? `(${timeZoneAbbreviation})` : ''}`;
|
||||
}
|
||||
|
||||
return [directionFormatted, amountFormatted, unitFormatted]
|
||||
.filter((item) => item !== undefined)
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getTimezoneAbbreviationForZonedDateTime } from '@/ui/input/components/internal/date/utils/getTimeZoneAbbreviationForZonedDateTime';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
import { type RelativeDateFilter } from 'twenty-shared/utils';
|
||||
|
||||
export const getRelativeDateFilterTimeZoneAbbreviation = (
|
||||
relativeDate: RelativeDateFilter,
|
||||
) => {
|
||||
const nowZonedDateTime = Temporal.Now.zonedDateTimeISO(
|
||||
relativeDate.timezone ?? 'UTC',
|
||||
);
|
||||
|
||||
const timeZoneAbbreviation =
|
||||
getTimezoneAbbreviationForZonedDateTime(nowZonedDateTime);
|
||||
|
||||
return timeZoneAbbreviation;
|
||||
};
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
|
||||
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
|
||||
import { isFieldMetadataReadOnlyByPermissions } from '@/object-record/read-only/utils/internal/isFieldMetadataReadOnlyByPermissions';
|
||||
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
|
||||
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
|
||||
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import { IconPlus } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { useRecordCalendarContextOrThrow } from '../contexts/RecordCalendarContext';
|
||||
@@ -18,12 +20,13 @@ const StyledButton = styled(Button)`
|
||||
`;
|
||||
|
||||
type RecordCalendarAddNewProps = {
|
||||
cardDate: string;
|
||||
cardDate: Temporal.PlainDate;
|
||||
};
|
||||
|
||||
export const RecordCalendarAddNew = ({
|
||||
cardDate,
|
||||
}: RecordCalendarAddNewProps) => {
|
||||
const { userTimezone } = useUserTimezone();
|
||||
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -69,7 +72,10 @@ export const RecordCalendarAddNew = ({
|
||||
<StyledButton
|
||||
onClick={() => {
|
||||
createNewIndexRecord({
|
||||
[calendarFieldMetadataItem.name]: cardDate,
|
||||
[calendarFieldMetadataItem.name]: cardDate
|
||||
.toZonedDateTime(userTimezone)
|
||||
.toInstant()
|
||||
.toString(),
|
||||
});
|
||||
}}
|
||||
variant="tertiary"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
|
||||
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
|
||||
import { DateTimePicker } from '@/ui/input/components/internal/date/components/DateTimePicker';
|
||||
import { DatePickerWithoutCalendar } from '@/ui/input/components/internal/date/components/DatePickerWithoutCalendar';
|
||||
import { TimeZoneAbbreviation } from '@/ui/input/components/internal/date/components/TimeZoneAbbreviation';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { SelectControl } from '@/ui/input/components/SelectControl';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
@@ -12,10 +13,14 @@ import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/com
|
||||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { addMonths, format, subMonths } from 'date-fns';
|
||||
import { format } from 'date-fns';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { type Nullable } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
isDefined,
|
||||
turnPlainDateToShiftedDateInSystemTimeZone,
|
||||
} from 'twenty-shared/utils';
|
||||
import { IconChevronLeft, IconChevronRight } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { ViewCalendarLayout } from '~/generated/graphql';
|
||||
@@ -60,26 +65,33 @@ export const RecordCalendarTopBar = () => {
|
||||
const datePickerDropdownId = `record-calendar-date-picker-${recordCalendarId}`;
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
|
||||
const handleDateChange = (date: Nullable<Date>) => {
|
||||
if (isDefined(date)) {
|
||||
setRecordCalendarSelectedDate(date);
|
||||
const handleDateChange = (plainDateString: Nullable<string>) => {
|
||||
if (isDefined(plainDateString)) {
|
||||
setRecordCalendarSelectedDate(Temporal.PlainDate.from(plainDateString));
|
||||
}
|
||||
closeDropdown(datePickerDropdownId);
|
||||
};
|
||||
|
||||
const handlePreviousMonth = () => {
|
||||
setRecordCalendarSelectedDate(subMonths(recordCalendarSelectedDate, 1));
|
||||
setRecordCalendarSelectedDate(
|
||||
recordCalendarSelectedDate.subtract({ months: 1 }),
|
||||
);
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
setRecordCalendarSelectedDate(addMonths(recordCalendarSelectedDate, 1));
|
||||
setRecordCalendarSelectedDate(
|
||||
recordCalendarSelectedDate?.add({ months: 1 }),
|
||||
);
|
||||
};
|
||||
|
||||
const handleTodayClick = () => {
|
||||
setRecordCalendarSelectedDate(new Date());
|
||||
setRecordCalendarSelectedDate(Temporal.Now.plainDateISO());
|
||||
};
|
||||
|
||||
const formattedDate = format(recordCalendarSelectedDate, 'MMMM yyyy');
|
||||
const formattedDate = format(
|
||||
turnPlainDateToShiftedDateInSystemTimeZone(recordCalendarSelectedDate),
|
||||
'MMMM yyyy',
|
||||
);
|
||||
|
||||
const dropdownContentOffset = { x: 140, y: 0 } satisfies DropdownOffset;
|
||||
|
||||
@@ -120,20 +132,19 @@ export const RecordCalendarTopBar = () => {
|
||||
}
|
||||
dropdownComponents={
|
||||
<DropdownContent widthInPixels={280}>
|
||||
<DateTimePicker
|
||||
<DatePickerWithoutCalendar
|
||||
instanceId={recordCalendarId}
|
||||
date={recordCalendarSelectedDate}
|
||||
date={recordCalendarSelectedDate.toString()}
|
||||
onChange={handleDateChange}
|
||||
onClose={handleDateChange}
|
||||
onEnter={handleDateChange}
|
||||
onEscape={handleDateChange}
|
||||
clearable={false}
|
||||
hideHeaderInput
|
||||
/>
|
||||
</DropdownContent>
|
||||
}
|
||||
dropdownOffset={dropdownContentOffset}
|
||||
/>
|
||||
<TimeZoneAbbreviation instant={Temporal.Now.instant()} />
|
||||
</StyledLeftSection>
|
||||
|
||||
<StyledNavigationSection>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
||||
import { useRecordCalendarGroupByRecords } from '@/object-record/record-calendar/hooks/useRecordCalendarGroupByRecords';
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { hasInitializedRecordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/hasInitializedRecordCalendarSelectedDateComponentState';
|
||||
import { recordCalendarRecordIdsComponentState } from '@/object-record/record-calendar/states/recordCalendarRecordIdsComponentState';
|
||||
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
|
||||
import { recordCalendarSelectedRecordIdsComponentSelector } from '@/object-record/record-calendar/states/selectors/recordCalendarSelectedRecordIdsComponentSelector';
|
||||
@@ -38,11 +39,24 @@ export const RecordIndexCalendarDataLoaderEffect = () => {
|
||||
recordCalendarSelectedDate,
|
||||
);
|
||||
|
||||
const hasInitializedRecordCalendarSelectedDate = useRecoilComponentValue(
|
||||
hasInitializedRecordCalendarSelectedDateComponentState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasInitializedRecordCalendarSelectedDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
upsertRecordsInStore({ partialRecords: records });
|
||||
const recordIds = records.map((record) => record.id);
|
||||
setRecordCalendarRecordIds(recordIds);
|
||||
}, [records, setRecordCalendarRecordIds, upsertRecordsInStore]);
|
||||
}, [
|
||||
hasInitializedRecordCalendarSelectedDate,
|
||||
records,
|
||||
setRecordCalendarRecordIds,
|
||||
upsertRecordsInStore,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setContextStoreTargetedRecords({
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { hasInitializedRecordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/hasInitializedRecordCalendarSelectedDateComponentState';
|
||||
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import { useEffect } from 'react';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const RecordIndexCalendarSelectedDateInitEffect = () => {
|
||||
const [, setRecordCalendarSelectedDate] = useRecoilComponentState(
|
||||
recordCalendarSelectedDateComponentState,
|
||||
);
|
||||
|
||||
const [
|
||||
hasInitializedRecordCalendarSelectedDate,
|
||||
setHasInitializedRecordCalendarSelectedDate,
|
||||
] = useRecoilComponentState(
|
||||
hasInitializedRecordCalendarSelectedDateComponentState,
|
||||
);
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasInitializedRecordCalendarSelectedDate) {
|
||||
setRecordCalendarSelectedDate(
|
||||
Temporal.Now.zonedDateTimeISO(userTimezone).toPlainDate(),
|
||||
);
|
||||
setHasInitializedRecordCalendarSelectedDate(true);
|
||||
}
|
||||
}, [
|
||||
hasInitializedRecordCalendarSelectedDate,
|
||||
setHasInitializedRecordCalendarSelectedDate,
|
||||
setRecordCalendarSelectedDate,
|
||||
userTimezone,
|
||||
]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
@@ -9,15 +9,21 @@ import { useRecordsFieldVisibleGqlFields } from '@/object-record/record-field/ho
|
||||
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { buildGroupByFieldObject } from '@/page-layout/widgets/graph/utils/buildGroupByFieldObject';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useQuery } from '@apollo/client';
|
||||
import { useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const useRecordCalendarGroupByRecords = (selectedDate: Date) => {
|
||||
export const useRecordCalendarGroupByRecords = (
|
||||
selectedDate: Temporal.PlainDate,
|
||||
) => {
|
||||
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const recordIndexCalendarFieldMetadataId = useRecoilValue(
|
||||
recordIndexCalendarFieldMetadataIdState,
|
||||
);
|
||||
@@ -40,6 +46,7 @@ export const useRecordCalendarGroupByRecords = (selectedDate: Date) => {
|
||||
buildGroupByFieldObject({
|
||||
field: calendarFieldMetadataItem,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
timeZone: userTimezone,
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type DragStart,
|
||||
type OnDragEndResponder,
|
||||
} from '@hello-pangea/dnd';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
@@ -26,6 +27,10 @@ export const RecordCalendarMonth = () => {
|
||||
recordCalendarSelectedDateComponentState,
|
||||
);
|
||||
|
||||
if (!isDefined(recordCalendarSelectedDate)) {
|
||||
throw new Error(`Cannot show RecordCalendarMonth without a selected date`);
|
||||
}
|
||||
|
||||
const { processCalendarCardDrop } = useProcessCalendarCardDrop();
|
||||
const { startRecordDrag } = useStartRecordDrag();
|
||||
const { endRecordDrag } = useEndRecordDrag();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { RecordCalendarMonthBodyWeek } from '@/object-record/record-calendar/month/components/RecordCalendarMonthBodyWeek';
|
||||
import { useRecordCalendarMonthContextOrThrow } from '@/object-record/record-calendar/month/contexts/RecordCalendarMonthContext';
|
||||
import styled from '@emotion/styled';
|
||||
import { format } from 'date-fns';
|
||||
import { DATE_TYPE_FORMAT } from 'twenty-shared/constants';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
@@ -20,7 +18,7 @@ export const RecordCalendarMonthBody = () => {
|
||||
<StyledContainer>
|
||||
{weekFirstDays.map((weekFirstDay) => (
|
||||
<RecordCalendarMonthBodyWeek
|
||||
key={`week-${format(weekFirstDay, DATE_TYPE_FORMAT)}`}
|
||||
key={`week-${weekFirstDay.toString()}`}
|
||||
startDayOfWeek={weekFirstDay}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { RecordCalendarCardDraggableContainer } from '@/object-record/record-calendar/record-calendar-card/components/RecordCalendarCardDraggableContainer';
|
||||
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
|
||||
import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { Droppable } from '@hello-pangea/dnd';
|
||||
import { format, isSameDay, isSameMonth, isWeekend } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
import { DATE_TYPE_FORMAT } from 'twenty-shared/constants';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import {
|
||||
isDefined,
|
||||
isPlainDateInSameMonth,
|
||||
isPlainDateInWeekend,
|
||||
isSamePlainDate,
|
||||
} from 'twenty-shared/utils';
|
||||
import { RecordCalendarAddNew } from '../../components/RecordCalendarAddNew';
|
||||
|
||||
const StyledContainer = styled.div<{
|
||||
@@ -94,34 +100,40 @@ const StyledCardsContainer = styled.div<{ isDraggedOver?: boolean }>`
|
||||
`;
|
||||
|
||||
type RecordCalendarMonthBodyDayProps = {
|
||||
day: Date;
|
||||
day: Temporal.PlainDate;
|
||||
};
|
||||
|
||||
export const RecordCalendarMonthBodyDay = ({
|
||||
day,
|
||||
}: RecordCalendarMonthBodyDayProps) => {
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const recordCalendarSelectedDate = useRecoilComponentValue(
|
||||
recordCalendarSelectedDateComponentState,
|
||||
);
|
||||
|
||||
const dayKey = format(day, DATE_TYPE_FORMAT);
|
||||
const dayKey = day.toString();
|
||||
|
||||
const recordIds = useRecoilComponentFamilyValue(
|
||||
calendarDayRecordIdsComponentFamilySelector,
|
||||
dayKey,
|
||||
{
|
||||
day: day,
|
||||
timeZone: userTimezone,
|
||||
},
|
||||
);
|
||||
|
||||
const todayInUserTimeZone =
|
||||
Temporal.Now.zonedDateTimeISO(userTimezone).toPlainDate();
|
||||
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const isToday = isSameDay(day, new Date());
|
||||
const isToday = isSamePlainDate(day, todayInUserTimeZone);
|
||||
|
||||
const isOtherMonth = !isSameMonth(day, recordCalendarSelectedDate);
|
||||
const isOtherMonth = isDefined(recordCalendarSelectedDate)
|
||||
? !isPlainDateInSameMonth(day, recordCalendarSelectedDate)
|
||||
: false;
|
||||
|
||||
const isDayOfWeekend = isWeekend(day);
|
||||
|
||||
const utcDate = new Date(
|
||||
Date.UTC(day.getFullYear(), day.getMonth(), day.getDate()),
|
||||
);
|
||||
const isDayOfWeekend = isPlainDateInWeekend(day);
|
||||
|
||||
return (
|
||||
<StyledContainer
|
||||
@@ -131,11 +143,9 @@ export const RecordCalendarMonthBodyDay = ({
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<StyledDayHeader>
|
||||
{hovered && <RecordCalendarAddNew cardDate={utcDate.toISOString()} />}
|
||||
{hovered && <RecordCalendarAddNew cardDate={day} />}
|
||||
<StyledDayHeaderDayContainer>
|
||||
<StyledDayHeaderDay isToday={isToday}>
|
||||
{day.getDate()}
|
||||
</StyledDayHeaderDay>
|
||||
<StyledDayHeaderDay isToday={isToday}>{day.day}</StyledDayHeaderDay>
|
||||
</StyledDayHeaderDayContainer>
|
||||
</StyledDayHeader>
|
||||
<Droppable droppableId={dayKey}>
|
||||
|
||||
@@ -2,6 +2,11 @@ import { RecordCalendarMonthBodyDay } from '@/object-record/record-calendar/mont
|
||||
import { useRecordCalendarMonthContextOrThrow } from '@/object-record/record-calendar/month/contexts/RecordCalendarMonthContext';
|
||||
import styled from '@emotion/styled';
|
||||
import { eachDayOfInterval, endOfWeek } from 'date-fns';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import {
|
||||
turnJSDateToPlainDate,
|
||||
turnPlainDateToShiftedDateInSystemTimeZone,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
@@ -14,7 +19,7 @@ const StyledContainer = styled.div`
|
||||
`;
|
||||
|
||||
type RecordCalendarMonthBodyWeekProps = {
|
||||
startDayOfWeek: Date;
|
||||
startDayOfWeek: Temporal.PlainDate;
|
||||
};
|
||||
|
||||
export const RecordCalendarMonthBodyWeek = ({
|
||||
@@ -23,8 +28,8 @@ export const RecordCalendarMonthBodyWeek = ({
|
||||
const { weekStartsOnDayIndex } = useRecordCalendarMonthContextOrThrow();
|
||||
|
||||
const daysOfWeek = eachDayOfInterval({
|
||||
start: startDayOfWeek,
|
||||
end: endOfWeek(startDayOfWeek, {
|
||||
start: turnPlainDateToShiftedDateInSystemTimeZone(startDayOfWeek),
|
||||
end: endOfWeek(turnPlainDateToShiftedDateInSystemTimeZone(startDayOfWeek), {
|
||||
weekStartsOn: weekStartsOnDayIndex,
|
||||
}),
|
||||
});
|
||||
@@ -32,7 +37,10 @@ export const RecordCalendarMonthBodyWeek = ({
|
||||
return (
|
||||
<StyledContainer>
|
||||
{daysOfWeek.map((day, index) => (
|
||||
<RecordCalendarMonthBodyDay key={`day-${index}`} day={day} />
|
||||
<RecordCalendarMonthBodyDay
|
||||
key={`day-${index}`}
|
||||
day={turnJSDateToPlainDate(day)}
|
||||
/>
|
||||
))}
|
||||
</StyledContainer>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import { createRequiredContext } from '~/utils/createRequiredContext';
|
||||
|
||||
type RecordCalendarMonthContextValue = {
|
||||
firstDayOfMonth: Date;
|
||||
lastDayOfMonth: Date;
|
||||
firstDayOfFirstWeek: Date;
|
||||
lastDayOfLastWeek: Date;
|
||||
firstDayOfMonth: Temporal.PlainDate;
|
||||
lastDayOfMonth: Temporal.PlainDate;
|
||||
firstDayOfFirstWeek: Temporal.PlainDate;
|
||||
lastDayOfLastWeek: Temporal.PlainDate;
|
||||
weekDayLabels: string[];
|
||||
weekFirstDays: Date[];
|
||||
weekFirstDays: Temporal.PlainDate[];
|
||||
weekStartsOnDayIndex: 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { CalendarStartDay } from 'twenty-shared';
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
|
||||
import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay';
|
||||
import {
|
||||
@@ -7,15 +7,22 @@ import {
|
||||
eachWeekOfInterval,
|
||||
endOfWeek,
|
||||
format,
|
||||
lastDayOfMonth as lastDayOfMonthFn,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
} from 'date-fns';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import {
|
||||
turnJSDateToPlainDate,
|
||||
turnPlainDateToShiftedDateInSystemTimeZone,
|
||||
} from 'twenty-shared/utils';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
|
||||
export const useRecordCalendarMonthDaysRange = (selectedDate: Date) => {
|
||||
// TODO: we could refactor this to use Temporal.PlainDate directly
|
||||
// But it would require recoding the utils here, not really worth it for now
|
||||
export const useRecordCalendarMonthDaysRange = (
|
||||
selectedDate: Temporal.PlainDate,
|
||||
) => {
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
const dateLocale = useRecoilValue(dateLocaleState);
|
||||
|
||||
@@ -29,36 +36,52 @@ export const useRecordCalendarMonthDaysRange = (selectedDate: Date) => {
|
||||
: (currentWorkspaceMember?.calendarStartDay ?? 0)
|
||||
) as 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
const firstDayOfMonth = startOfMonth(selectedDate);
|
||||
const lastDayOfMonth = lastDayOfMonthFn(selectedDate);
|
||||
const firstDayOfMonth = selectedDate.with({ day: 1 });
|
||||
const lastDayOfMonth = selectedDate
|
||||
.with({ day: 1 })
|
||||
.add({ months: 1 })
|
||||
.subtract({ days: 1 });
|
||||
|
||||
const firstDayOfFirstWeek = startOfWeek(firstDayOfMonth, {
|
||||
weekStartsOn: weekStartsOnDayIndex,
|
||||
locale: dateLocale.localeCatalog,
|
||||
});
|
||||
const shiftedFirstDayOfMonth =
|
||||
turnPlainDateToShiftedDateInSystemTimeZone(firstDayOfMonth);
|
||||
|
||||
const lastDayOfLastWeek = endOfWeek(lastDayOfMonth, {
|
||||
weekStartsOn: weekStartsOnDayIndex,
|
||||
locale: dateLocale.localeCatalog,
|
||||
});
|
||||
const firstDayOfFirstWeek = turnJSDateToPlainDate(
|
||||
startOfWeek(shiftedFirstDayOfMonth, {
|
||||
weekStartsOn: weekStartsOnDayIndex,
|
||||
locale: dateLocale.localeCatalog,
|
||||
}),
|
||||
);
|
||||
|
||||
const shiftedLastDayOfMonth =
|
||||
turnPlainDateToShiftedDateInSystemTimeZone(lastDayOfMonth);
|
||||
|
||||
const lastDayOfLastWeek = turnJSDateToPlainDate(
|
||||
endOfWeek(shiftedLastDayOfMonth, {
|
||||
weekStartsOn: weekStartsOnDayIndex,
|
||||
locale: dateLocale.localeCatalog,
|
||||
}),
|
||||
);
|
||||
|
||||
const daysOfWeekLabels: string[] = [];
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = addDays(firstDayOfFirstWeek, i);
|
||||
const day = addDays(
|
||||
turnPlainDateToShiftedDateInSystemTimeZone(firstDayOfFirstWeek),
|
||||
i,
|
||||
);
|
||||
const label = format(day, 'EEE', { locale: dateLocale.localeCatalog });
|
||||
daysOfWeekLabels.push(label);
|
||||
}
|
||||
|
||||
const weekFirstDays = eachWeekOfInterval(
|
||||
{
|
||||
start: firstDayOfFirstWeek,
|
||||
end: lastDayOfLastWeek,
|
||||
start: turnPlainDateToShiftedDateInSystemTimeZone(firstDayOfFirstWeek),
|
||||
end: turnPlainDateToShiftedDateInSystemTimeZone(lastDayOfLastWeek),
|
||||
},
|
||||
{
|
||||
weekStartsOn: weekStartsOnDayIndex,
|
||||
},
|
||||
);
|
||||
).map(turnJSDateToPlainDate);
|
||||
|
||||
return {
|
||||
weekStartsOnDayIndex,
|
||||
|
||||
@@ -6,24 +6,31 @@ import { anyFieldFilterValueComponentState } from '@/object-record/record-filter
|
||||
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import {
|
||||
combineFilters,
|
||||
computeRecordGqlOperationFilter,
|
||||
isDefined,
|
||||
turnAnyFieldFilterIntoRecordGqlFilter,
|
||||
turnPlainDateIntoUserTimeZoneInstantString,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
const DATE_RANGE_FILTER_AFTER_ID = 'DATE_RANGE_FILTER_AFTER_ID';
|
||||
const DATE_RANGE_FILTER_BEFORE_ID = 'DATE_RANGE_FILTER_BEFORE_ID';
|
||||
|
||||
export const useRecordCalendarQueryDateRangeFilter = (selectedDate: Date) => {
|
||||
export const useRecordCalendarQueryDateRangeFilter = (
|
||||
selectedDate: Temporal.PlainDate,
|
||||
) => {
|
||||
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
|
||||
const { firstDayOfFirstWeek, lastDayOfLastWeek } =
|
||||
useRecordCalendarMonthDaysRange(selectedDate);
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const { currentView } = useGetCurrentViewOnly();
|
||||
|
||||
const currentRecordFilterGroups = useRecoilComponentValue(
|
||||
@@ -49,26 +56,38 @@ export const useRecordCalendarQueryDateRangeFilter = (selectedDate: Date) => {
|
||||
};
|
||||
}
|
||||
|
||||
const firstDayOfFirstWeekISOString =
|
||||
turnPlainDateIntoUserTimeZoneInstantString(
|
||||
firstDayOfFirstWeek,
|
||||
userTimezone,
|
||||
);
|
||||
|
||||
const nextDayAfterLastDayOfLastWeekISOString =
|
||||
turnPlainDateIntoUserTimeZoneInstantString(
|
||||
lastDayOfLastWeek.add({ days: 1 }),
|
||||
userTimezone,
|
||||
);
|
||||
|
||||
const dateRangeFilterFieldMetadataId = currentView.calendarFieldMetadataId;
|
||||
|
||||
const dateRangeFilterAfter: RecordFilter = {
|
||||
id: DATE_RANGE_FILTER_AFTER_ID,
|
||||
fieldMetadataId: dateRangeFilterFieldMetadataId,
|
||||
value: `${firstDayOfFirstWeek.toISOString()}`,
|
||||
value: `${firstDayOfFirstWeekISOString}`,
|
||||
operand: RecordFilterOperand.IS_AFTER,
|
||||
type: 'DATE',
|
||||
label: t`After`,
|
||||
displayValue: `${firstDayOfFirstWeek.toISOString()}`,
|
||||
type: 'DATE_TIME',
|
||||
label: t`After or equal`,
|
||||
displayValue: `${firstDayOfFirstWeek.toString()}`,
|
||||
};
|
||||
|
||||
const dateRangeFilterBefore: RecordFilter = {
|
||||
id: DATE_RANGE_FILTER_BEFORE_ID,
|
||||
fieldMetadataId: dateRangeFilterFieldMetadataId,
|
||||
value: `${lastDayOfLastWeek.toISOString()}`,
|
||||
value: `${nextDayAfterLastDayOfLastWeekISOString}`,
|
||||
operand: RecordFilterOperand.IS_BEFORE,
|
||||
type: 'DATE',
|
||||
type: 'DATE_TIME',
|
||||
label: t`Before`,
|
||||
displayValue: `${lastDayOfLastWeek.toISOString()}`,
|
||||
displayValue: `${lastDayOfLastWeek.toString()}`,
|
||||
};
|
||||
|
||||
const dateRangeFilter = computeRecordGqlOperationFilter({
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
|
||||
|
||||
export const hasInitializedRecordCalendarSelectedDateComponentState =
|
||||
createComponentState<boolean>({
|
||||
key: 'hasInitializedRecordCalendarSelectedDateComponentState',
|
||||
defaultValue: false,
|
||||
componentInstanceContext: RecordCalendarComponentInstanceContext,
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const recordCalendarSelectedDateComponentState =
|
||||
createComponentState<Date>({
|
||||
createComponentState<Temporal.PlainDate>({
|
||||
key: 'recordCalendarSelectedDateComponentState',
|
||||
defaultValue: new Date(),
|
||||
defaultValue: Temporal.Now.plainDateISO(),
|
||||
componentInstanceContext: RecordCalendarComponentInstanceContext,
|
||||
});
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { hasObjectMetadataItemPositionField } from '@/object-metadata/utils/hasObjectMetadataItemPositionField';
|
||||
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { recordCalendarRecordIdsComponentState } from '@/object-record/record-calendar/states/recordCalendarRecordIdsComponentState';
|
||||
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { createComponentFamilySelector } from '@/ui/utilities/state/component-state/utils/createComponentFamilySelector';
|
||||
import { isSameDay, parse } from 'date-fns';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { isDefined, isSamePlainDate } from 'twenty-shared/utils';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
export const calendarDayRecordIdsComponentFamilySelector =
|
||||
createComponentFamilySelector<string[], string>({
|
||||
createComponentFamilySelector<
|
||||
string[],
|
||||
{ day: Temporal.PlainDate; timeZone: string }
|
||||
>({
|
||||
key: 'calendarDayRecordsComponentFamilySelector',
|
||||
componentInstanceContext: RecordCalendarComponentInstanceContext,
|
||||
get:
|
||||
({ instanceId, familyKey: dayAsString }) =>
|
||||
({ instanceId, familyKey: { day, timeZone } }) =>
|
||||
({ get }) => {
|
||||
const calendarFieldMetadataId = get(
|
||||
recordIndexCalendarFieldMetadataIdState,
|
||||
@@ -51,14 +58,18 @@ export const calendarDayRecordIdsComponentFamilySelector =
|
||||
const record = get(recordStoreFamilyState(recordId));
|
||||
const recordDate = record?.[fieldMetadataItem.name];
|
||||
|
||||
if (!recordDate) {
|
||||
if (!isNonEmptyString(recordDate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dayDate = parse(dayAsString, 'yyyy-MM-dd', new Date());
|
||||
const recordDateObj = new Date(recordDate);
|
||||
const recordDateAsPlainDateInTimeZone =
|
||||
fieldMetadataItem.type === FieldMetadataType.DATE
|
||||
? Temporal.PlainDate.from(recordDate)
|
||||
: Temporal.Instant.from(recordDate)
|
||||
.toZonedDateTimeISO(timeZone)
|
||||
.toPlainDate();
|
||||
|
||||
return isSameDay(recordDateObj, dayDate);
|
||||
return isSamePlainDate(day, recordDateAsPlainDateInTimeZone);
|
||||
});
|
||||
|
||||
if (
|
||||
|
||||
@@ -6,20 +6,12 @@ import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar
|
||||
import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector';
|
||||
|
||||
import { extractRecordPositions } from '@/object-record/record-drag/utils/extractRecordPositions';
|
||||
import { isFieldDateTime } from '@/object-record/record-field/ui/types/guards/isFieldDateTime';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { computeNewPositionOfDraggedRecord } from '@/object-record/utils/computeNewPositionOfDraggedRecord';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
|
||||
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
|
||||
import {
|
||||
formatISO,
|
||||
getHours,
|
||||
getMilliseconds,
|
||||
getMinutes,
|
||||
getSeconds,
|
||||
parse,
|
||||
set,
|
||||
} from 'date-fns';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const useProcessCalendarCardDrop = () => {
|
||||
@@ -29,6 +21,8 @@ export const useProcessCalendarCardDrop = () => {
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
});
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const calendarDayRecordIdsSelector = useRecoilComponentCallbackState(
|
||||
calendarDayRecordIdsComponentFamilySelector,
|
||||
);
|
||||
@@ -46,6 +40,8 @@ export const useProcessCalendarCardDrop = () => {
|
||||
const destinationDate = calendarCardDropResult.destination.droppableId;
|
||||
const destinationIndex = calendarCardDropResult.destination.index;
|
||||
|
||||
const destinationPlainDate = Temporal.PlainDate.from(destinationDate);
|
||||
|
||||
const record = snapshot
|
||||
.getLoadable(recordStoreFamilyState(recordId))
|
||||
.getValue();
|
||||
@@ -59,7 +55,12 @@ export const useProcessCalendarCardDrop = () => {
|
||||
if (!calendarFieldMetadata) return;
|
||||
|
||||
const destinationRecordIds = snapshot
|
||||
.getLoadable(calendarDayRecordIdsSelector(destinationDate))
|
||||
.getLoadable(
|
||||
calendarDayRecordIdsSelector({
|
||||
day: destinationPlainDate,
|
||||
timeZone: userTimezone,
|
||||
}),
|
||||
)
|
||||
.getValue() as string[];
|
||||
|
||||
const targetDayIsEmpty = destinationRecordIds.length === 0;
|
||||
@@ -73,9 +74,15 @@ export const useProcessCalendarCardDrop = () => {
|
||||
destinationRecordIds,
|
||||
snapshot,
|
||||
);
|
||||
const droppedRecordIsFromAnotherList = !recordsWithPosition
|
||||
.map((recordWithPosition) => recordWithPosition.id)
|
||||
.includes(recordId);
|
||||
|
||||
const isDroppedAfterList =
|
||||
destinationIndex >= recordsWithPosition.length;
|
||||
(recordsWithPosition.length === 2 &&
|
||||
destinationIndex === 1 &&
|
||||
!droppedRecordIsFromAnotherList) ||
|
||||
destinationIndex === recordsWithPosition.length;
|
||||
|
||||
const targetRecord = isDroppedAfterList
|
||||
? recordsWithPosition.at(-1)
|
||||
@@ -95,38 +102,38 @@ export const useProcessCalendarCardDrop = () => {
|
||||
});
|
||||
}
|
||||
|
||||
const targetDate = parse(destinationDate, 'yyyy-MM-dd', new Date());
|
||||
const currentFieldValue = record[calendarFieldMetadata.name];
|
||||
let newDate: Date;
|
||||
|
||||
if (
|
||||
isDefined(currentFieldValue) &&
|
||||
isFieldDateTime(calendarFieldMetadata)
|
||||
) {
|
||||
const currentDateTime = new Date(currentFieldValue);
|
||||
newDate = set(targetDate, {
|
||||
hours: getHours(currentDateTime),
|
||||
minutes: getMinutes(currentDateTime),
|
||||
seconds: getSeconds(currentDateTime),
|
||||
milliseconds: getMilliseconds(currentDateTime),
|
||||
});
|
||||
} else {
|
||||
newDate = targetDate;
|
||||
}
|
||||
const currentZonedDateTime = isDefined(currentFieldValue)
|
||||
? Temporal.Instant.from(currentFieldValue).toZonedDateTimeISO(
|
||||
userTimezone,
|
||||
)
|
||||
: null;
|
||||
|
||||
const newDate = isDefined(currentZonedDateTime)
|
||||
? currentZonedDateTime.with({
|
||||
day: destinationPlainDate.day,
|
||||
month: destinationPlainDate.month,
|
||||
year: destinationPlainDate.year,
|
||||
})
|
||||
: Temporal.PlainDate.from(destinationPlainDate).toZonedDateTime(
|
||||
userTimezone,
|
||||
);
|
||||
|
||||
await updateOneRecord({
|
||||
idToUpdate: recordId,
|
||||
updateOneRecordInput: {
|
||||
[calendarFieldMetadata.name]: formatISO(newDate),
|
||||
[calendarFieldMetadata.name]: newDate.toInstant().toString(),
|
||||
position: newPosition,
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
objectMetadataItem,
|
||||
currentView,
|
||||
updateOneRecord,
|
||||
objectMetadataItem.fields,
|
||||
calendarDayRecordIdsSelector,
|
||||
userTimezone,
|
||||
updateOneRecord,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ type FormFieldInputProps = {
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
onError?: (error: string | undefined) => void;
|
||||
timeZone?: string;
|
||||
};
|
||||
|
||||
export const FormFieldInput = ({
|
||||
@@ -73,6 +74,7 @@ export const FormFieldInput = ({
|
||||
placeholder,
|
||||
error,
|
||||
onError,
|
||||
timeZone,
|
||||
}: FormFieldInputProps) => {
|
||||
return isFieldNumber(field) || field.type === FieldMetadataType.NUMERIC ? (
|
||||
<FormNumberFieldInput
|
||||
@@ -167,6 +169,7 @@ export const FormFieldInput = ({
|
||||
onChange={onChange}
|
||||
VariablePicker={VariablePicker}
|
||||
readonly={readonly}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
) : isFieldMultiSelect(field) ? (
|
||||
<FormMultiSelectFieldInput
|
||||
|
||||
@@ -338,7 +338,7 @@ export const FormDateFieldInput = ({
|
||||
<OverlayContainer>
|
||||
<DatePicker
|
||||
instanceId={instanceId}
|
||||
date={pickerDate}
|
||||
plainDateString={pickerDate}
|
||||
onChange={handlePickerChange}
|
||||
onClose={handlePickerMouseSelect}
|
||||
onEnter={handlePickerEnter}
|
||||
|
||||
@@ -9,29 +9,20 @@ import {
|
||||
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
|
||||
MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID,
|
||||
} from '@/ui/input/components/internal/date/components/DateTimePicker';
|
||||
import { MAX_DATE } from '@/ui/input/components/internal/date/constants/MaxDate';
|
||||
import { MIN_DATE } from '@/ui/input/components/internal/date/constants/MinDate';
|
||||
import { useParseDateTimeInputStringToJSDate } from '@/ui/input/components/internal/date/hooks/useParseDateTimeInputStringToJSDate';
|
||||
import { useParseJSDateToIMaskDateTimeInputString } from '@/ui/input/components/internal/date/hooks/useParseJSDateToIMaskDateTimeInputString';
|
||||
import { DateTimePickerInput } from '@/ui/input/components/internal/date/components/DateTimePickerInput';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
|
||||
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import {
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
import { useId, useRef, useState } from 'react';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { TEXT_INPUT_STYLE } from 'twenty-ui/theme';
|
||||
import { type Nullable } from 'twenty-ui/utilities';
|
||||
|
||||
const StyledInputContainer = styled(FormFieldInputInnerContainer)`
|
||||
@@ -47,18 +38,9 @@ const StyledDateInputAbsoluteContainer = styled.div`
|
||||
top: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledDateInput = styled.input<{ hasError?: boolean }>`
|
||||
${TEXT_INPUT_STYLE}
|
||||
|
||||
&:disabled {
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
}
|
||||
|
||||
${({ hasError, theme }) =>
|
||||
hasError &&
|
||||
css`
|
||||
color: ${theme.color.red};
|
||||
`};
|
||||
const StyledDateInputTextContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const StyledDateInputContainer = styled.div`
|
||||
@@ -84,6 +66,7 @@ type FormDateTimeFieldInputProps = {
|
||||
placeholder?: string;
|
||||
VariablePicker?: VariablePickerComponent;
|
||||
readonly?: boolean;
|
||||
timeZone?: string;
|
||||
};
|
||||
|
||||
export const FormDateTimeFieldInput = ({
|
||||
@@ -92,15 +75,10 @@ export const FormDateTimeFieldInput = ({
|
||||
onChange,
|
||||
VariablePicker,
|
||||
readonly,
|
||||
placeholder,
|
||||
timeZone,
|
||||
}: FormDateTimeFieldInputProps) => {
|
||||
const instanceId = useId();
|
||||
|
||||
const { parseJSDateToDateTimeInputString: parseDateTimeToString } =
|
||||
useParseJSDateToIMaskDateTimeInputString();
|
||||
const { parseDateTimeInputStringToJSDate: parseStringToDateTime } =
|
||||
useParseDateTimeInputStringToJSDate();
|
||||
|
||||
const [draftValue, setDraftValue] = useState<DraftValue>(
|
||||
isStandaloneVariableString(defaultValue)
|
||||
? {
|
||||
@@ -109,34 +87,18 @@ export const FormDateTimeFieldInput = ({
|
||||
}
|
||||
: {
|
||||
type: 'static',
|
||||
value: defaultValue ?? null,
|
||||
value: defaultValue !== 'null' ? (defaultValue ?? null) : null,
|
||||
mode: 'view',
|
||||
},
|
||||
);
|
||||
|
||||
const draftValueAsDate =
|
||||
isDefined(draftValue.value) &&
|
||||
isNonEmptyString(draftValue.value) &&
|
||||
draftValue.type === 'static'
|
||||
? new Date(draftValue.value)
|
||||
: null;
|
||||
|
||||
const [pickerDate, setPickerDate] =
|
||||
useState<Nullable<Date>>(draftValueAsDate);
|
||||
|
||||
const datePickerWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [inputDateTime, setInputDateTime] = useState(
|
||||
isDefined(draftValueAsDate) && !isStandaloneVariableString(defaultValue)
|
||||
? parseDateTimeToString(draftValueAsDate)
|
||||
: '',
|
||||
);
|
||||
|
||||
const persistDate = (newDate: Nullable<Date>) => {
|
||||
const persistDate = (newDate: Nullable<Temporal.ZonedDateTime>) => {
|
||||
if (!isDefined(newDate)) {
|
||||
onChange(null);
|
||||
} else {
|
||||
const newDateISO = newDate.toISOString();
|
||||
const newDateISO = newDate.toInstant().toString();
|
||||
|
||||
onChange(newDateISO);
|
||||
}
|
||||
@@ -148,8 +110,6 @@ export const FormDateTimeFieldInput = ({
|
||||
const displayDatePicker =
|
||||
draftValue.type === 'static' && draftValue.mode === 'edit';
|
||||
|
||||
const placeholderToDisplay = placeholder ?? 'mm/dd/yyyy hh:mm';
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [datePickerWrapperRef],
|
||||
listenerId: 'FormDateTimeFieldInputBase',
|
||||
@@ -167,17 +127,13 @@ export const FormDateTimeFieldInput = ({
|
||||
],
|
||||
});
|
||||
|
||||
const handlePickerChange = (newDate: Nullable<Date>) => {
|
||||
const handlePickerChange = (newDate: Nullable<Temporal.ZonedDateTime>) => {
|
||||
setDraftValue({
|
||||
type: 'static',
|
||||
mode: 'edit',
|
||||
value: newDate?.toDateString() ?? null,
|
||||
value: newDate?.toPlainDate().toString() ?? null,
|
||||
});
|
||||
|
||||
setInputDateTime(isDefined(newDate) ? parseDateTimeToString(newDate) : '');
|
||||
|
||||
setPickerDate(newDate);
|
||||
|
||||
persistDate(newDate);
|
||||
};
|
||||
|
||||
@@ -208,24 +164,18 @@ export const FormDateTimeFieldInput = ({
|
||||
mode: 'view',
|
||||
});
|
||||
|
||||
setPickerDate(null);
|
||||
|
||||
setInputDateTime('');
|
||||
|
||||
persistDate(null);
|
||||
};
|
||||
|
||||
const handlePickerMouseSelect = (newDate: Nullable<Date>) => {
|
||||
const handlePickerMouseSelect = (
|
||||
newDate: Nullable<Temporal.ZonedDateTime>,
|
||||
) => {
|
||||
setDraftValue({
|
||||
type: 'static',
|
||||
value: newDate?.toDateString() ?? null,
|
||||
value: newDate?.toPlainDate().toString() ?? null,
|
||||
mode: 'view',
|
||||
});
|
||||
|
||||
setPickerDate(newDate);
|
||||
|
||||
setInputDateTime(isDefined(newDate) ? parseDateTimeToString(newDate) : '');
|
||||
|
||||
persistDate(newDate);
|
||||
};
|
||||
|
||||
@@ -237,46 +187,18 @@ export const FormDateTimeFieldInput = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setInputDateTime(event.target.value);
|
||||
};
|
||||
|
||||
const handleInputKeydown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key !== 'Enter') {
|
||||
const handleInputChange = (newDate: Temporal.ZonedDateTime | null) => {
|
||||
if (!isDefined(newDate)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inputDateTimeTrimmed = inputDateTime.trim();
|
||||
|
||||
if (inputDateTimeTrimmed === '') {
|
||||
handlePickerClear();
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedInputDateTime = parseStringToDateTime(inputDateTimeTrimmed);
|
||||
|
||||
if (!isDefined(parsedInputDateTime)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let validatedDate = parsedInputDateTime;
|
||||
if (parsedInputDateTime < MIN_DATE) {
|
||||
validatedDate = MIN_DATE;
|
||||
} else if (parsedInputDateTime > MAX_DATE) {
|
||||
validatedDate = MAX_DATE;
|
||||
}
|
||||
|
||||
setDraftValue({
|
||||
type: 'static',
|
||||
value: validatedDate.toDateString(),
|
||||
mode: 'edit',
|
||||
value: newDate.toPlainDate().toString(),
|
||||
});
|
||||
|
||||
setPickerDate(validatedDate);
|
||||
|
||||
setInputDateTime(parseDateTimeToString(validatedDate));
|
||||
|
||||
persistDate(validatedDate);
|
||||
persistDate(newDate);
|
||||
};
|
||||
|
||||
const handleVariableTagInsert = (variableName: string) => {
|
||||
@@ -285,8 +207,6 @@ export const FormDateTimeFieldInput = ({
|
||||
value: variableName,
|
||||
});
|
||||
|
||||
setInputDateTime('');
|
||||
|
||||
onChange(variableName);
|
||||
};
|
||||
|
||||
@@ -297,8 +217,6 @@ export const FormDateTimeFieldInput = ({
|
||||
mode: 'view',
|
||||
});
|
||||
|
||||
setPickerDate(null);
|
||||
|
||||
onChange(null);
|
||||
};
|
||||
|
||||
@@ -309,6 +227,16 @@ export const FormDateTimeFieldInput = ({
|
||||
dependencies: [handlePickerEscape],
|
||||
});
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const dateValue = isStandaloneVariableString(defaultValue)
|
||||
? null
|
||||
: defaultValue === 'null' || defaultValue === '' || !isDefined(defaultValue)
|
||||
? null
|
||||
: Temporal.Instant.from(defaultValue).toZonedDateTimeISO(
|
||||
timeZone ?? userTimezone,
|
||||
);
|
||||
|
||||
return (
|
||||
<FormFieldInputContainer>
|
||||
{label ? <InputLabel>{label}</InputLabel> : null}
|
||||
@@ -321,29 +249,29 @@ export const FormDateTimeFieldInput = ({
|
||||
>
|
||||
{draftValue.type === 'static' ? (
|
||||
<>
|
||||
<StyledDateInput
|
||||
type="text"
|
||||
placeholder={placeholderToDisplay}
|
||||
value={inputDateTime}
|
||||
onFocus={handleInputFocus}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeydown}
|
||||
disabled={readonly}
|
||||
/>
|
||||
|
||||
<StyledDateInputTextContainer>
|
||||
<DateTimePickerInput
|
||||
date={dateValue}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
readonly={readonly}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
</StyledDateInputTextContainer>
|
||||
{draftValue.mode === 'edit' ? (
|
||||
<StyledDateInputContainer>
|
||||
<StyledDateInputAbsoluteContainer>
|
||||
<OverlayContainer>
|
||||
<DateTimePicker
|
||||
instanceId={instanceId}
|
||||
date={pickerDate ?? new Date()}
|
||||
date={dateValue}
|
||||
onChange={handlePickerChange}
|
||||
onClose={handlePickerMouseSelect}
|
||||
onEnter={handlePickerEnter}
|
||||
onEscape={handlePickerEscape}
|
||||
onClear={handlePickerClear}
|
||||
hideHeaderInput
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
</OverlayContainer>
|
||||
</StyledDateInputAbsoluteContainer>
|
||||
@@ -357,7 +285,6 @@ export const FormDateTimeFieldInput = ({
|
||||
/>
|
||||
)}
|
||||
</StyledInputContainer>
|
||||
|
||||
{VariablePicker && !readonly ? (
|
||||
<VariablePicker
|
||||
instanceId={instanceId}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts
|
||||
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
|
||||
import { DateTimeInput } from '@/ui/field/input/components/DateTimeInput';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useContext } from 'react';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { type Nullable } from 'twenty-ui/utilities';
|
||||
import { useDateTimeField } from '../../hooks/useDateTimeField';
|
||||
|
||||
@@ -17,44 +19,46 @@ export const DateTimeFieldInput = () => {
|
||||
RecordFieldComponentInstanceContext,
|
||||
);
|
||||
|
||||
const getDateToPersist = (newDate: Nullable<Date>) => {
|
||||
if (!newDate) {
|
||||
const getDateToPersist = (newInstant: Nullable<Temporal.Instant>) => {
|
||||
if (!newInstant) {
|
||||
return null;
|
||||
} else {
|
||||
const newDateISO = newDate?.toISOString();
|
||||
const newDateISO = newInstant?.toString();
|
||||
|
||||
return newDateISO;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnter = (newDate: Nullable<Date>) => {
|
||||
const handleEnter = (newDate: Nullable<Temporal.Instant>) => {
|
||||
onEnter?.({ newValue: getDateToPersist(newDate) });
|
||||
};
|
||||
|
||||
const handleEscape = (newDate: Nullable<Date>) => {
|
||||
const handleEscape = (newDate: Nullable<Temporal.Instant>) => {
|
||||
onEscape?.({ newValue: getDateToPersist(newDate) });
|
||||
};
|
||||
|
||||
const handleClickOutside = (
|
||||
event: MouseEvent | TouchEvent,
|
||||
newDate: Nullable<Date>,
|
||||
newDate: Nullable<Temporal.Instant>,
|
||||
) => {
|
||||
onClickOutside?.({ newValue: getDateToPersist(newDate), event });
|
||||
};
|
||||
|
||||
const handleChange = (newDate: Nullable<Date>) => {
|
||||
setDraftValue(newDate?.toDateString() ?? '');
|
||||
const handleChange = (newInstant: Nullable<Temporal.Instant>) => {
|
||||
setDraftValue(newInstant?.toString() ?? '');
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onSubmit?.({ newValue: null });
|
||||
};
|
||||
|
||||
const handleSubmit = (newDate: Nullable<Date>) => {
|
||||
onSubmit?.({ newValue: getDateToPersist(newDate) });
|
||||
const handleSubmit = (newInstant: Nullable<Temporal.Instant>) => {
|
||||
onSubmit?.({ newValue: getDateToPersist(newInstant) });
|
||||
};
|
||||
|
||||
const dateValue = fieldValue ? new Date(fieldValue) : null;
|
||||
const dateValue = isNonEmptyString(fieldValue)
|
||||
? Temporal.Instant.from(fieldValue)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<DateTimeInput
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { type RecordFilterValueDependencies } from 'twenty-shared/types';
|
||||
|
||||
@@ -8,9 +9,12 @@ export const useFilterValueDependencies = (): {
|
||||
const { id: currentWorkspaceMemberId } =
|
||||
useRecoilValue(currentWorkspaceMemberState) ?? {};
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
return {
|
||||
filterValueDependencies: {
|
||||
currentWorkspaceMemberId,
|
||||
timeZone: userTimezone,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
import { useGetFieldMetadataItemByIdOrThrow } from '@/object-metadata/hooks/useGetFieldMetadataItemById';
|
||||
import { useGetDateFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateFilterDisplayValue';
|
||||
import { useGetDateTimeFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue';
|
||||
import { getRelativeDateDisplayValue } from '@/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue';
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
|
||||
import { isRecordFilterConsideredEmpty } from '@/object-record/record-filter/utils/isRecordFilterConsideredEmpty';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { UserContext } from '@/users/contexts/UserContext';
|
||||
import { isValid } from 'date-fns';
|
||||
import { useContext } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { getTimezoneAbbreviationForZonedDateTime } from '@/ui/input/components/internal/date/utils/getTimeZoneAbbreviationForZonedDateTime';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { type Nullable } from 'twenty-shared/types';
|
||||
import {
|
||||
getDateFromPlainDate,
|
||||
isDefined,
|
||||
isEmptinessOperand,
|
||||
parseJson,
|
||||
relativeDateFilterStringifiedSchema,
|
||||
shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone,
|
||||
} from 'twenty-shared/utils';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
import { formatDateString } from '~/utils/string/formatDateString';
|
||||
|
||||
// TODO: finish the implementation of this hook to obtain filter display value and remove deprecated display value property
|
||||
export const useGetRecordFilterDisplayValue = () => {
|
||||
const { dateFormat, timeZone } = useContext(UserContext);
|
||||
const dateLocale = useRecoilValue(dateLocaleState);
|
||||
const { userTimezone } = useUserTimezone();
|
||||
const { isSystemTimezone, userTimezone } = useUserTimezone();
|
||||
|
||||
const { getDateTimeFilterDisplayValue } = useGetDateTimeFilterDisplayValue();
|
||||
const { getDateFilterDisplayValue } = useGetDateFilterDisplayValue();
|
||||
|
||||
const { getFieldMetadataItemByIdOrThrow } =
|
||||
useGetFieldMetadataItemByIdOrThrow();
|
||||
@@ -44,65 +39,25 @@ export const useGetRecordFilterDisplayValue = () => {
|
||||
const operandIsEmptiness = isEmptinessOperand(recordFilter.operand);
|
||||
const recordFilterIsEmpty = isRecordFilterConsideredEmpty(recordFilter);
|
||||
|
||||
const nowInZonedDateTime = Temporal.Now.zonedDateTimeISO(userTimezone);
|
||||
|
||||
const shouldDisplayTimeZoneAbbreviation = !isSystemTimezone;
|
||||
const timeZoneAbbreviation =
|
||||
getTimezoneAbbreviationForZonedDateTime(nowInZonedDateTime);
|
||||
|
||||
if (filterType === 'DATE') {
|
||||
switch (recordFilter.operand) {
|
||||
case RecordFilterOperand.IS: {
|
||||
const date = getDateFromPlainDate(recordFilter.value);
|
||||
|
||||
const shiftedDate =
|
||||
shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone(
|
||||
date,
|
||||
userTimezone,
|
||||
'add',
|
||||
);
|
||||
|
||||
if (!isValid(date)) {
|
||||
if (!isDefined(recordFilter.value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const formattedDate = formatDateString({
|
||||
value: shiftedDate.toISOString(),
|
||||
timeZone,
|
||||
dateFormat,
|
||||
localeCatalog: dateLocale.localeCatalog,
|
||||
});
|
||||
const plainDate = Temporal.PlainDate.from(recordFilter.value);
|
||||
|
||||
return `${formattedDate}`;
|
||||
}
|
||||
case RecordFilterOperand.IS_RELATIVE: {
|
||||
const relativeDateFilter =
|
||||
relativeDateFilterStringifiedSchema.safeParse(recordFilter.value);
|
||||
|
||||
if (!relativeDateFilter.success) {
|
||||
return ``;
|
||||
}
|
||||
|
||||
const relativeDateDisplayValue = getRelativeDateDisplayValue(
|
||||
relativeDateFilter.data,
|
||||
const { displayValue } = getDateFilterDisplayValue(
|
||||
plainDate.toZonedDateTime(userTimezone),
|
||||
);
|
||||
|
||||
return ` ${relativeDateDisplayValue}`;
|
||||
}
|
||||
case RecordFilterOperand.IS_TODAY:
|
||||
case RecordFilterOperand.IS_IN_FUTURE:
|
||||
case RecordFilterOperand.IS_IN_PAST:
|
||||
return '';
|
||||
default:
|
||||
return ` ${recordFilter.displayValue}`;
|
||||
}
|
||||
} else if (recordFilter.type === 'DATE_TIME') {
|
||||
switch (recordFilter.operand) {
|
||||
case RecordFilterOperand.IS:
|
||||
case RecordFilterOperand.IS_AFTER:
|
||||
case RecordFilterOperand.IS_BEFORE: {
|
||||
const pointInTime = new Date(recordFilter.value);
|
||||
|
||||
if (!isValid(pointInTime)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { displayValue } = getDateTimeFilterDisplayValue(pointInTime);
|
||||
|
||||
return `${displayValue}`;
|
||||
}
|
||||
case RecordFilterOperand.IS_RELATIVE: {
|
||||
@@ -115,11 +70,57 @@ export const useGetRecordFilterDisplayValue = () => {
|
||||
|
||||
const relativeDateDisplayValue = getRelativeDateDisplayValue(
|
||||
relativeDateFilter.data,
|
||||
shouldDisplayTimeZoneAbbreviation,
|
||||
);
|
||||
|
||||
return ` ${relativeDateDisplayValue}`;
|
||||
}
|
||||
case RecordFilterOperand.IS_TODAY:
|
||||
return shouldDisplayTimeZoneAbbreviation
|
||||
? `(${timeZoneAbbreviation})`
|
||||
: '';
|
||||
case RecordFilterOperand.IS_IN_FUTURE:
|
||||
case RecordFilterOperand.IS_IN_PAST:
|
||||
return '';
|
||||
default:
|
||||
return ` ${recordFilter.displayValue}`;
|
||||
}
|
||||
} else if (recordFilter.type === 'DATE_TIME') {
|
||||
switch (recordFilter.operand) {
|
||||
case RecordFilterOperand.IS:
|
||||
case RecordFilterOperand.IS_AFTER:
|
||||
case RecordFilterOperand.IS_BEFORE: {
|
||||
if (!isNonEmptyString(recordFilter.value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const zonedDateTime = Temporal.Instant.from(
|
||||
recordFilter.value,
|
||||
).toZonedDateTimeISO(userTimezone);
|
||||
|
||||
const { displayValue } = getDateTimeFilterDisplayValue(zonedDateTime);
|
||||
|
||||
return `${displayValue}`;
|
||||
}
|
||||
case RecordFilterOperand.IS_RELATIVE: {
|
||||
const relativeDateFilter =
|
||||
relativeDateFilterStringifiedSchema.safeParse(recordFilter.value);
|
||||
|
||||
if (!relativeDateFilter.success) {
|
||||
return ``;
|
||||
}
|
||||
|
||||
const relativeDateDisplayValue = getRelativeDateDisplayValue(
|
||||
relativeDateFilter.data,
|
||||
shouldDisplayTimeZoneAbbreviation,
|
||||
);
|
||||
|
||||
return `${relativeDateDisplayValue}`;
|
||||
}
|
||||
case RecordFilterOperand.IS_TODAY:
|
||||
return shouldDisplayTimeZoneAbbreviation
|
||||
? `(${timeZoneAbbreviation})`
|
||||
: '';
|
||||
case RecordFilterOperand.IS_IN_FUTURE:
|
||||
case RecordFilterOperand.IS_IN_PAST:
|
||||
return '';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
|
||||
import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { CalendarStartDay } from 'twenty-shared';
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
import { type FirstDayOfTheWeek } from 'twenty-shared/types';
|
||||
import { type RelativeDateFilter } from 'twenty-shared/utils';
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { getTimezoneAbbreviationForZonedDateTime } from '@/ui/input/components/internal/date/utils/getTimeZoneAbbreviationForZonedDateTime';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const useTimeZoneAbbreviationForNowInUserTimeZone = () => {
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const nowZonedDateTime = Temporal.Now.zonedDateTimeISO(userTimezone);
|
||||
|
||||
const userTimeZoneAbbreviation =
|
||||
getTimezoneAbbreviationForZonedDateTime(nowZonedDateTime);
|
||||
|
||||
return { userTimeZoneAbbreviation };
|
||||
};
|
||||
@@ -26,6 +26,7 @@ const personMockObjectMetadataItem = getMockObjectMetadataItemOrThrow('person');
|
||||
|
||||
const mockFilterValueDependencies: RecordFilterValueDependencies = {
|
||||
currentWorkspaceMemberId: '32219445-f587-4c40-b2b1-6d3205ed96da',
|
||||
timeZone: 'Europe/Paris',
|
||||
};
|
||||
|
||||
jest.useFakeTimers().setSystemTime(new Date('2020-01-01'));
|
||||
@@ -1068,7 +1069,7 @@ describe('should work as expected for the different field types', () => {
|
||||
and: [
|
||||
{
|
||||
createdAt: {
|
||||
gt: '2024-09-17T20:46:58.922Z',
|
||||
gte: '2024-09-17T20:46:58.922Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1080,12 +1081,12 @@ describe('should work as expected for the different field types', () => {
|
||||
and: [
|
||||
{
|
||||
createdAt: {
|
||||
lte: '2024-09-17T20:46:59.999Z',
|
||||
lt: '2024-09-17T20:47:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
createdAt: {
|
||||
gte: '2024-09-17T20:46:00.000Z',
|
||||
gte: '2024-09-17T20:46:00Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { RecordComponentInstanceContextsWrapper } from '@/object-record/componen
|
||||
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
|
||||
import { RecordCalendar } from '@/object-record/record-calendar/components/RecordCalendar';
|
||||
import { RecordIndexCalendarDataLoaderEffect } from '@/object-record/record-calendar/components/RecordIndexCalendarDataLoaderEffect';
|
||||
import { RecordIndexCalendarSelectedDateInitEffect } from '@/object-record/record-calendar/components/RecordIndexCalendarSelectedDateInitEffect';
|
||||
import { RecordCalendarContextProvider } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
|
||||
|
||||
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
|
||||
@@ -52,6 +53,7 @@ export const RecordIndexCalendarContainer = ({
|
||||
>
|
||||
<RecordCalendar />
|
||||
<RecordIndexCalendarDataLoaderEffect />
|
||||
<RecordIndexCalendarSelectedDateInitEffect />
|
||||
</RecordCalendarContextProvider>
|
||||
</RecordComponentInstanceContextsWrapper>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
computeRecordGqlOperationFilter,
|
||||
turnAnyFieldFilterIntoRecordGqlFilter,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
export const useFindManyRecordIndexTableParams = (
|
||||
objectNameSingular: string,
|
||||
) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRecordsFieldVisibleGqlFields } from '@/object-record/record-field/ho
|
||||
import { useFindManyRecordIndexTableParams } from '@/object-record/record-index/hooks/useFindManyRecordIndexTableParams';
|
||||
import { SIGN_IN_BACKGROUND_MOCK_COMPANIES } from '@/sign-in-background-mock/constants/SignInBackgroundMockCompanies';
|
||||
import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal';
|
||||
|
||||
export const useRecordIndexTableQuery = (objectNameSingular: string) => {
|
||||
const showAuthModal = useShowAuthModal();
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ const computeValueFromFilterText = (
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: fix this with Temporal
|
||||
const computeValueFromFilterDate = (
|
||||
operand: RecordFilterToRecordInputOperand<'DATE_TIME'>,
|
||||
value: string,
|
||||
|
||||
@@ -7,6 +7,8 @@ import { assertBarChartWidgetOrThrow } from '@/page-layout/widgets/graph/utils/a
|
||||
import { buildChartDrilldownQueryParams } from '@/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams';
|
||||
import { generateChartAggregateFilterKey } from '@/page-layout/widgets/graph/utils/generateChartAggregateFilterKey';
|
||||
import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget';
|
||||
import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { coreIndexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreIndexViewIdFromObjectMetadataItemFamilySelector';
|
||||
import { type BarDatum, type ComputedDatum } from '@nivo/bar';
|
||||
@@ -29,6 +31,9 @@ export const GraphWidgetBarChartRenderer = () => {
|
||||
|
||||
assertBarChartWidgetOrThrow(widget);
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek();
|
||||
|
||||
const {
|
||||
data,
|
||||
indexBy,
|
||||
@@ -84,7 +89,8 @@ export const GraphWidgetBarChartRenderer = () => {
|
||||
primaryBucketRawValue: rawValue,
|
||||
},
|
||||
viewId: indexViewId,
|
||||
timezone: configuration.timezone ?? undefined,
|
||||
timezone: userTimezone,
|
||||
firstDayOfTheWeek: userFirstDayOfTheWeek,
|
||||
});
|
||||
|
||||
const url = getAppPath(
|
||||
|
||||
@@ -6,6 +6,8 @@ import { getBarChartQueryLimit } from '@/page-layout/widgets/graph/graphWidgetBa
|
||||
import { transformGroupByDataToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData';
|
||||
import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery';
|
||||
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
|
||||
import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { type BarDatum } from '@nivo/bar';
|
||||
import { useMemo } from 'react';
|
||||
import { type BarChartConfiguration } from '~/generated/graphql';
|
||||
@@ -43,6 +45,9 @@ export const useGraphBarChartWidgetData = ({
|
||||
});
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek();
|
||||
|
||||
const limit = getBarChartQueryLimit(configuration);
|
||||
|
||||
const {
|
||||
@@ -64,6 +69,8 @@ export const useGraphBarChartWidgetData = ({
|
||||
objectMetadataItems: objectMetadataItems ?? [],
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: userFirstDayOfTheWeek,
|
||||
}),
|
||||
[
|
||||
groupByData,
|
||||
@@ -71,6 +78,8 @@ export const useGraphBarChartWidgetData = ({
|
||||
objectMetadataItems,
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
userTimezone,
|
||||
userFirstDayOfTheWeek,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ describe('fillDateGapsInBarChartData', () => {
|
||||
it('fills gaps in date data with zero values', () => {
|
||||
const data = [
|
||||
{
|
||||
groupByDimensionValues: ['2024-01-01T00:00:00.000Z'],
|
||||
groupByDimensionValues: ['2024-01-01'],
|
||||
count: 5,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-01-03T00:00:00.000Z'],
|
||||
groupByDimensionValues: ['2024-01-03'],
|
||||
count: 3,
|
||||
},
|
||||
];
|
||||
@@ -24,15 +24,15 @@ describe('fillDateGapsInBarChartData', () => {
|
||||
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0]).toEqual({
|
||||
groupByDimensionValues: ['2024-01-01T00:00:00.000Z'],
|
||||
groupByDimensionValues: ['2024-01-01'],
|
||||
count: 5,
|
||||
});
|
||||
expect(result.data[1]).toEqual({
|
||||
groupByDimensionValues: ['2024-01-02T00:00:00.000Z'],
|
||||
groupByDimensionValues: ['2024-01-02'],
|
||||
count: 0,
|
||||
});
|
||||
expect(result.data[2]).toEqual({
|
||||
groupByDimensionValues: ['2024-01-03T00:00:00.000Z'],
|
||||
groupByDimensionValues: ['2024-01-03'],
|
||||
count: 3,
|
||||
});
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
@@ -52,11 +52,11 @@ describe('fillDateGapsInBarChartData', () => {
|
||||
it('returns data in descending order when orderBy is FIELD_DESC', () => {
|
||||
const data = [
|
||||
{
|
||||
groupByDimensionValues: ['2024-01-01T00:00:00.000Z'],
|
||||
groupByDimensionValues: ['2024-01-01'],
|
||||
count: 5,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-01-03T00:00:00.000Z'],
|
||||
groupByDimensionValues: ['2024-01-03'],
|
||||
count: 3,
|
||||
},
|
||||
];
|
||||
@@ -70,15 +70,15 @@ describe('fillDateGapsInBarChartData', () => {
|
||||
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0]).toEqual({
|
||||
groupByDimensionValues: ['2024-01-03T00:00:00.000Z'],
|
||||
groupByDimensionValues: ['2024-01-03'],
|
||||
count: 3,
|
||||
});
|
||||
expect(result.data[1]).toEqual({
|
||||
groupByDimensionValues: ['2024-01-02T00:00:00.000Z'],
|
||||
groupByDimensionValues: ['2024-01-02'],
|
||||
count: 0,
|
||||
});
|
||||
expect(result.data[2]).toEqual({
|
||||
groupByDimensionValues: ['2024-01-01T00:00:00.000Z'],
|
||||
groupByDimensionValues: ['2024-01-01'],
|
||||
count: 5,
|
||||
});
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
@@ -87,11 +87,11 @@ describe('fillDateGapsInBarChartData', () => {
|
||||
it('returns data in ascending order when orderBy is FIELD_ASC', () => {
|
||||
const data = [
|
||||
{
|
||||
groupByDimensionValues: ['2024-01-01T00:00:00.000Z'],
|
||||
groupByDimensionValues: ['2024-01-01'],
|
||||
count: 5,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-01-03T00:00:00.000Z'],
|
||||
groupByDimensionValues: ['2024-01-03'],
|
||||
count: 3,
|
||||
},
|
||||
];
|
||||
@@ -105,15 +105,15 @@ describe('fillDateGapsInBarChartData', () => {
|
||||
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0]).toEqual({
|
||||
groupByDimensionValues: ['2024-01-01T00:00:00.000Z'],
|
||||
groupByDimensionValues: ['2024-01-01'],
|
||||
count: 5,
|
||||
});
|
||||
expect(result.data[1]).toEqual({
|
||||
groupByDimensionValues: ['2024-01-02T00:00:00.000Z'],
|
||||
groupByDimensionValues: ['2024-01-02'],
|
||||
count: 0,
|
||||
});
|
||||
expect(result.data[2]).toEqual({
|
||||
groupByDimensionValues: ['2024-01-03T00:00:00.000Z'],
|
||||
groupByDimensionValues: ['2024-01-03'],
|
||||
count: 3,
|
||||
});
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
@@ -124,15 +124,15 @@ describe('fillDateGapsInBarChartData', () => {
|
||||
it('fills gaps for all second dimension values', () => {
|
||||
const data = [
|
||||
{
|
||||
groupByDimensionValues: ['2024-01-01T00:00:00.000Z', 'A'],
|
||||
groupByDimensionValues: ['2024-01-01', 'A'],
|
||||
count: 5,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-01-03T00:00:00.000Z', 'A'],
|
||||
groupByDimensionValues: ['2024-01-03', 'A'],
|
||||
count: 3,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-01-01T00:00:00.000Z', 'B'],
|
||||
groupByDimensionValues: ['2024-01-01', 'B'],
|
||||
count: 2,
|
||||
},
|
||||
];
|
||||
@@ -154,11 +154,11 @@ describe('fillDateGapsInBarChartData', () => {
|
||||
expect(
|
||||
result.data.find(
|
||||
(r) =>
|
||||
r.groupByDimensionValues[0] === '2024-01-02T00:00:00.000Z' &&
|
||||
r.groupByDimensionValues[0] === '2024-01-02' &&
|
||||
r.groupByDimensionValues[1] === 'A',
|
||||
),
|
||||
).toEqual({
|
||||
groupByDimensionValues: ['2024-01-02T00:00:00.000Z', 'A'],
|
||||
groupByDimensionValues: ['2024-01-02', 'A'],
|
||||
count: 0,
|
||||
});
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
@@ -167,15 +167,15 @@ describe('fillDateGapsInBarChartData', () => {
|
||||
it('fills gaps in descending order when orderBy is FIELD_DESC', () => {
|
||||
const data = [
|
||||
{
|
||||
groupByDimensionValues: ['2024-01-01T00:00:00.000Z', 'A'],
|
||||
groupByDimensionValues: ['2024-01-01', 'A'],
|
||||
count: 5,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-01-03T00:00:00.000Z', 'A'],
|
||||
groupByDimensionValues: ['2024-01-03', 'A'],
|
||||
count: 3,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-01-01T00:00:00.000Z', 'B'],
|
||||
groupByDimensionValues: ['2024-01-01', 'B'],
|
||||
count: 2,
|
||||
},
|
||||
];
|
||||
@@ -191,28 +191,16 @@ describe('fillDateGapsInBarChartData', () => {
|
||||
expect(result.data).toHaveLength(6);
|
||||
|
||||
// First date group should be Jan 3 (descending)
|
||||
expect(result.data[0].groupByDimensionValues[0]).toBe(
|
||||
'2024-01-03T00:00:00.000Z',
|
||||
);
|
||||
expect(result.data[1].groupByDimensionValues[0]).toBe(
|
||||
'2024-01-03T00:00:00.000Z',
|
||||
);
|
||||
expect(result.data[0].groupByDimensionValues[0]).toBe('2024-01-03');
|
||||
expect(result.data[1].groupByDimensionValues[0]).toBe('2024-01-03');
|
||||
|
||||
// Middle date group should be Jan 2
|
||||
expect(result.data[2].groupByDimensionValues[0]).toBe(
|
||||
'2024-01-02T00:00:00.000Z',
|
||||
);
|
||||
expect(result.data[3].groupByDimensionValues[0]).toBe(
|
||||
'2024-01-02T00:00:00.000Z',
|
||||
);
|
||||
expect(result.data[2].groupByDimensionValues[0]).toBe('2024-01-02');
|
||||
expect(result.data[3].groupByDimensionValues[0]).toBe('2024-01-02');
|
||||
|
||||
// Last date group should be Jan 1
|
||||
expect(result.data[4].groupByDimensionValues[0]).toBe(
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
);
|
||||
expect(result.data[5].groupByDimensionValues[0]).toBe(
|
||||
'2024-01-01T00:00:00.000Z',
|
||||
);
|
||||
expect(result.data[4].groupByDimensionValues[0]).toBe('2024-01-01');
|
||||
expect(result.data[5].groupByDimensionValues[0]).toBe('2024-01-01');
|
||||
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,64 +1,81 @@
|
||||
import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import { generateDateGroupsInRange } from '../generateDateGroupsInRange';
|
||||
|
||||
describe('generateDateGroupsInRange', () => {
|
||||
it('generates daily date groups', () => {
|
||||
const result = generateDateGroupsInRange({
|
||||
startDate: new Date('2024-01-01'),
|
||||
endDate: new Date('2024-01-07'),
|
||||
startDate: Temporal.PlainDate.from('2024-01-01'),
|
||||
endDate: Temporal.PlainDate.from('2024-01-07'),
|
||||
granularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
});
|
||||
|
||||
expect(result.dates).toHaveLength(7);
|
||||
expect(result.dates[0]).toEqual(new Date('2024-01-01'));
|
||||
expect(result.dates[6]).toEqual(new Date('2024-01-07'));
|
||||
expect(result.dates[0].toString()).toEqual(
|
||||
Temporal.PlainDate.from('2024-01-01').toString(),
|
||||
);
|
||||
expect(result.dates[6].toString()).toEqual(
|
||||
Temporal.PlainDate.from('2024-01-07').toString(),
|
||||
);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('generates monthly date groups', () => {
|
||||
const result = generateDateGroupsInRange({
|
||||
startDate: new Date('2024-01-01'),
|
||||
endDate: new Date('2024-06-01'),
|
||||
startDate: Temporal.PlainDate.from('2024-01-01'),
|
||||
endDate: Temporal.PlainDate.from('2024-06-01'),
|
||||
granularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
});
|
||||
|
||||
expect(result.dates).toHaveLength(6);
|
||||
expect(result.dates[0]).toEqual(new Date('2024-01-01'));
|
||||
expect(result.dates[5]).toEqual(new Date('2024-06-01'));
|
||||
expect(result.dates[0].toString()).toEqual(
|
||||
Temporal.PlainDate.from('2024-01-01').toString(),
|
||||
);
|
||||
expect(result.dates[5].toString()).toEqual(
|
||||
Temporal.PlainDate.from('2024-06-01').toString(),
|
||||
);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('generates quarterly date groups', () => {
|
||||
const result = generateDateGroupsInRange({
|
||||
startDate: new Date('2024-01-01'),
|
||||
endDate: new Date('2024-12-31'),
|
||||
startDate: Temporal.PlainDate.from('2024-01-01'),
|
||||
endDate: Temporal.PlainDate.from('2024-12-31'),
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
});
|
||||
|
||||
expect(result.dates).toHaveLength(4);
|
||||
expect(result.dates[0]).toEqual(new Date('2024-01-01'));
|
||||
expect(result.dates[3]).toEqual(new Date('2024-10-01'));
|
||||
expect(result.dates[0].toString()).toEqual(
|
||||
Temporal.PlainDate.from('2024-01-01').toString(),
|
||||
);
|
||||
expect(result.dates[3].toString()).toEqual(
|
||||
Temporal.PlainDate.from('2024-10-01').toString(),
|
||||
);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('generates yearly date groups', () => {
|
||||
const result = generateDateGroupsInRange({
|
||||
startDate: new Date('2020-01-01'),
|
||||
endDate: new Date('2024-12-31'),
|
||||
startDate: Temporal.PlainDate.from('2020-01-01'),
|
||||
endDate: Temporal.PlainDate.from('2024-12-31'),
|
||||
granularity: ObjectRecordGroupByDateGranularity.YEAR,
|
||||
});
|
||||
|
||||
expect(result.dates).toHaveLength(5);
|
||||
expect(result.dates[0]).toEqual(new Date('2020-01-01'));
|
||||
expect(result.dates[4]).toEqual(new Date('2024-01-01'));
|
||||
expect(result.dates[0].toString()).toEqual(
|
||||
Temporal.PlainDate.from('2020-01-01').toString(),
|
||||
);
|
||||
expect(result.dates[4].toString()).toEqual(
|
||||
Temporal.PlainDate.from('2024-01-01').toString(),
|
||||
);
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it('truncates when exceeding maximum number of bars', () => {
|
||||
const result = generateDateGroupsInRange({
|
||||
startDate: new Date('2024-01-01'),
|
||||
endDate: new Date('2025-12-31'),
|
||||
startDate: Temporal.PlainDate.from('2024-01-01'),
|
||||
endDate: Temporal.PlainDate.from('2025-12-31'),
|
||||
granularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { transformGroupByDataToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
FirstDayOfTheWeek,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
import { GraphType } from '~/generated-metadata/graphql';
|
||||
@@ -39,6 +40,8 @@ const { fillDateGapsInBarChartData } = jest.requireMock(
|
||||
) as { fillDateGapsInBarChartData: jest.Mock };
|
||||
|
||||
describe('transformGroupByDataToBarChartData', () => {
|
||||
const userTimezone = 'Europe/Paris';
|
||||
|
||||
it('fills date gaps when grouping by a relation date subfield with granularity', () => {
|
||||
const groupByField = {
|
||||
id: 'group-by-field',
|
||||
@@ -91,6 +94,8 @@ describe('transformGroupByDataToBarChartData', () => {
|
||||
objectMetadataItems,
|
||||
configuration,
|
||||
aggregateOperation: 'COUNT',
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
expect(fillDateGapsInBarChartData).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
|
||||
import { FirstDayOfTheWeek } from 'twenty-shared/types';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
AggregateOperations,
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
import { transformTwoDimensionalGroupByToBarChartData } from '../transformTwoDimensionalGroupByToBarChartData';
|
||||
|
||||
describe('transformTwoDimensionalGroupByToBarChartData', () => {
|
||||
const userTimezone = 'Europe/Paris';
|
||||
|
||||
const mockGroupByFieldX = {
|
||||
id: 'field-x',
|
||||
name: 'createdAt',
|
||||
@@ -85,6 +88,8 @@ describe('transformTwoDimensionalGroupByToBarChartData', () => {
|
||||
configuration: mockConfiguration,
|
||||
aggregateOperation: 'sumAmount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
expect(result.keys).toEqual(['SCREENING', 'PROPOSAL', 'NEW', 'CUSTOMER']);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
export type DimensionValue = string | Date | number | null;
|
||||
export type DimensionValue = string | Temporal.PlainDate | number | null;
|
||||
|
||||
export const createEmptyDateGroup = (
|
||||
dimensionValues: DimensionValue[],
|
||||
@@ -8,7 +9,7 @@ export const createEmptyDateGroup = (
|
||||
): GroupByRawResult => {
|
||||
const newItem: GroupByRawResult = {
|
||||
groupByDimensionValues: dimensionValues.map((value) =>
|
||||
value instanceof Date ? value.toISOString() : value,
|
||||
value instanceof Temporal.PlainDate ? value.toString() : value,
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type SupportedDateGranularity,
|
||||
} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData';
|
||||
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type GraphOrderBy } from '~/generated/graphql';
|
||||
|
||||
@@ -15,6 +16,7 @@ type OneDimensionalFillParams = {
|
||||
orderBy?: GraphOrderBy | null;
|
||||
};
|
||||
|
||||
// TODO: should handle DATE and DATE_TIME here
|
||||
export const fillDateGapsInOneDimensionalBarChartData = ({
|
||||
data,
|
||||
keys,
|
||||
@@ -22,7 +24,7 @@ export const fillDateGapsInOneDimensionalBarChartData = ({
|
||||
orderBy,
|
||||
}: OneDimensionalFillParams): FillDateGapsResult => {
|
||||
const existingDateGroupsMap = new Map<string, GroupByRawResult>();
|
||||
const parsedDates: Date[] = [];
|
||||
const parsedDates: Temporal.PlainDate[] = [];
|
||||
|
||||
for (const item of data) {
|
||||
const dateValue = item.groupByDimensionValues?.[0];
|
||||
@@ -31,14 +33,10 @@ export const fillDateGapsInOneDimensionalBarChartData = ({
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedDate = new Date(String(dateValue));
|
||||
|
||||
if (isNaN(parsedDate.getTime())) {
|
||||
continue;
|
||||
}
|
||||
const parsedDate = Temporal.PlainDate.from(String(dateValue));
|
||||
|
||||
parsedDates.push(parsedDate);
|
||||
existingDateGroupsMap.set(parsedDate.toISOString(), item);
|
||||
existingDateGroupsMap.set(parsedDate.toString(), item);
|
||||
}
|
||||
|
||||
if (parsedDates.length === 0) {
|
||||
@@ -52,7 +50,7 @@ export const fillDateGapsInOneDimensionalBarChartData = ({
|
||||
});
|
||||
|
||||
const filledData = allDates.map((date) => {
|
||||
const key = date.toISOString();
|
||||
const key = date.toString();
|
||||
const existingDateGroup = existingDateGroupsMap.get(key);
|
||||
|
||||
return isDefined(existingDateGroup)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type SupportedDateGranularity,
|
||||
} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData';
|
||||
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type GraphOrderBy } from '~/generated/graphql';
|
||||
|
||||
@@ -25,7 +26,7 @@ export const fillDateGapsInTwoDimensionalBarChartData = ({
|
||||
orderBy,
|
||||
}: TwoDimensionalFillParams): FillDateGapsResult => {
|
||||
const existingDateGroupsMap = new Map<string, GroupByRawResult>();
|
||||
const parsedDates: Date[] = [];
|
||||
const parsedDates: Temporal.PlainDate[] = [];
|
||||
const uniqueSecondDimensionValues = new Set<DimensionValue>();
|
||||
|
||||
for (const item of data) {
|
||||
@@ -35,11 +36,7 @@ export const fillDateGapsInTwoDimensionalBarChartData = ({
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedDate = new Date(String(dateValue));
|
||||
|
||||
if (isNaN(parsedDate.getTime())) {
|
||||
continue;
|
||||
}
|
||||
const parsedDate = Temporal.PlainDate.from(String(dateValue));
|
||||
|
||||
parsedDates.push(parsedDate);
|
||||
|
||||
@@ -47,7 +44,7 @@ export const fillDateGapsInTwoDimensionalBarChartData = ({
|
||||
null) as DimensionValue;
|
||||
uniqueSecondDimensionValues.add(secondDimensionValue);
|
||||
|
||||
const key = `${parsedDate.toISOString()}_${String(secondDimensionValue)}`;
|
||||
const key = `${parsedDate.toString()}_${String(secondDimensionValue)}`;
|
||||
existingDateGroupsMap.set(key, item);
|
||||
}
|
||||
|
||||
@@ -63,7 +60,7 @@ export const fillDateGapsInTwoDimensionalBarChartData = ({
|
||||
|
||||
const filledData = allDates.flatMap((date) =>
|
||||
Array.from(uniqueSecondDimensionValues).map((secondDimensionValue) => {
|
||||
const key = `${date.toISOString()}_${String(secondDimensionValue)}`;
|
||||
const key = `${date.toString()}_${String(secondDimensionValue)}`;
|
||||
const existingDateGroup = existingDateGroupsMap.get(key);
|
||||
|
||||
return isDefined(existingDateGroup)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants';
|
||||
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
import {
|
||||
assertUnreachable,
|
||||
isPlainDateBeforeOrEqual,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
type GenerateDateRangeParams = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
startDate: Temporal.PlainDate;
|
||||
endDate: Temporal.PlainDate;
|
||||
granularity:
|
||||
| ObjectRecordGroupByDateGranularity.DAY
|
||||
| ObjectRecordGroupByDateGranularity.WEEK
|
||||
@@ -14,7 +19,7 @@ type GenerateDateRangeParams = {
|
||||
};
|
||||
|
||||
type GenerateDateRangeResult = {
|
||||
dates: Date[];
|
||||
dates: Temporal.PlainDate[];
|
||||
wasTruncated: boolean;
|
||||
};
|
||||
|
||||
@@ -23,41 +28,41 @@ export const generateDateGroupsInRange = ({
|
||||
endDate,
|
||||
granularity,
|
||||
}: GenerateDateRangeParams): GenerateDateRangeResult => {
|
||||
const dates: Date[] = [];
|
||||
const dates: Temporal.PlainDate[] = [];
|
||||
|
||||
let iterations = 0;
|
||||
let wasTruncated = false;
|
||||
|
||||
let currentDateCursor = new Date(startDate);
|
||||
let currentDateCursor = startDate;
|
||||
|
||||
while (currentDateCursor <= endDate) {
|
||||
while (isPlainDateBeforeOrEqual(currentDateCursor, endDate)) {
|
||||
if (iterations >= BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS) {
|
||||
wasTruncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
dates.push(new Date(currentDateCursor));
|
||||
dates.push(currentDateCursor);
|
||||
iterations++;
|
||||
|
||||
switch (granularity) {
|
||||
case ObjectRecordGroupByDateGranularity.DAY:
|
||||
currentDateCursor.setDate(currentDateCursor.getDate() + 1);
|
||||
currentDateCursor = currentDateCursor.add({ days: 1 });
|
||||
break;
|
||||
|
||||
case ObjectRecordGroupByDateGranularity.WEEK:
|
||||
currentDateCursor.setDate(currentDateCursor.getDate() + 7);
|
||||
currentDateCursor = currentDateCursor.add({ weeks: 1 });
|
||||
break;
|
||||
|
||||
case ObjectRecordGroupByDateGranularity.MONTH:
|
||||
currentDateCursor.setMonth(currentDateCursor.getMonth() + 1);
|
||||
currentDateCursor = currentDateCursor.add({ months: 1 });
|
||||
break;
|
||||
|
||||
case ObjectRecordGroupByDateGranularity.QUARTER:
|
||||
currentDateCursor.setMonth(currentDateCursor.getMonth() + 3);
|
||||
currentDateCursor = currentDateCursor.add({ months: 3 });
|
||||
break;
|
||||
|
||||
case ObjectRecordGroupByDateGranularity.YEAR:
|
||||
currentDateCursor.setFullYear(currentDateCursor.getFullYear() + 1);
|
||||
currentDateCursor = currentDateCursor.add({ years: 1 });
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { generateDateGroupsInRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/generateDateGroupsInRange';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import { isDefined, sortPlainDate } from 'twenty-shared/utils';
|
||||
import { GraphOrderBy } from '~/generated/graphql';
|
||||
|
||||
export type SupportedDateGranularity =
|
||||
@@ -10,7 +12,7 @@ export type SupportedDateGranularity =
|
||||
| ObjectRecordGroupByDateGranularity.WEEK;
|
||||
|
||||
type GetDateGroupsFromDataParams = {
|
||||
parsedDates: Date[];
|
||||
parsedDates: Temporal.PlainDate[];
|
||||
dateGranularity: SupportedDateGranularity;
|
||||
orderBy?: GraphOrderBy | null;
|
||||
};
|
||||
@@ -19,10 +21,18 @@ export const getDateGroupsFromData = ({
|
||||
parsedDates,
|
||||
dateGranularity,
|
||||
orderBy,
|
||||
}: GetDateGroupsFromDataParams): { dates: Date[]; wasTruncated: boolean } => {
|
||||
const timestamps = parsedDates.map((date) => date.getTime());
|
||||
const minDate = new Date(Math.min(...timestamps));
|
||||
const maxDate = new Date(Math.max(...timestamps));
|
||||
}: GetDateGroupsFromDataParams): {
|
||||
dates: Temporal.PlainDate[];
|
||||
wasTruncated: boolean;
|
||||
} => {
|
||||
const sortedPlainDates = parsedDates.toSorted(sortPlainDate('asc'));
|
||||
|
||||
const minDate = sortedPlainDates.at(0);
|
||||
const maxDate = sortedPlainDates.at(-1);
|
||||
|
||||
if (!isDefined(minDate) || !isDefined(maxDate)) {
|
||||
return { dates: [], wasTruncated: false };
|
||||
}
|
||||
|
||||
const result = generateDateGroupsInRange({
|
||||
startDate: minDate,
|
||||
|
||||
@@ -15,7 +15,11 @@ import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGr
|
||||
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
|
||||
import { isRelationNestedFieldDateKind } from '@/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind';
|
||||
import { type BarDatum } from '@nivo/bar';
|
||||
import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils';
|
||||
import {
|
||||
type FirstDayOfTheWeek,
|
||||
isDefined,
|
||||
isFieldMetadataDateKind,
|
||||
} from 'twenty-shared/utils';
|
||||
import { GraphType } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
AxisNameDisplay,
|
||||
@@ -28,6 +32,8 @@ type TransformGroupByDataToBarChartDataParams = {
|
||||
objectMetadataItems: ObjectMetadataItem[];
|
||||
configuration: BarChartConfiguration;
|
||||
aggregateOperation: string;
|
||||
userTimezone: string;
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek;
|
||||
};
|
||||
|
||||
type TransformGroupByDataToBarChartDataResult = {
|
||||
@@ -65,6 +71,8 @@ export const transformGroupByDataToBarChartData = ({
|
||||
objectMetadataItems,
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
}: TransformGroupByDataToBarChartDataParams): TransformGroupByDataToBarChartDataResult => {
|
||||
const groupByFieldX = objectMetadataItem.fields.find(
|
||||
(field: FieldMetadataItem) =>
|
||||
@@ -243,6 +251,8 @@ export const transformGroupByDataToBarChartData = ({
|
||||
aggregateOperation,
|
||||
objectMetadataItem,
|
||||
primaryAxisSubFieldName,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
})
|
||||
: transformOneDimensionalGroupByToBarChartData({
|
||||
rawResults: filteredResultsWithDateGaps,
|
||||
@@ -252,6 +262,8 @@ export const transformGroupByDataToBarChartData = ({
|
||||
aggregateOperation,
|
||||
objectMetadataItem,
|
||||
primaryAxisSubFieldName,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDi
|
||||
import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues';
|
||||
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
|
||||
import { type BarDatum } from '@nivo/bar';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type FirstDayOfTheWeek, isDefined } from 'twenty-shared/utils';
|
||||
import { type BarChartConfiguration } from '~/generated/graphql';
|
||||
|
||||
type TransformOneDimensionalGroupByToBarChartDataParams = {
|
||||
@@ -25,6 +25,8 @@ type TransformOneDimensionalGroupByToBarChartDataParams = {
|
||||
aggregateOperation: string;
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
primaryAxisSubFieldName?: string | null;
|
||||
userTimezone: string;
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek;
|
||||
};
|
||||
|
||||
type TransformOneDimensionalGroupByToBarChartDataResult = {
|
||||
@@ -44,6 +46,8 @@ export const transformOneDimensionalGroupByToBarChartData = ({
|
||||
aggregateOperation,
|
||||
objectMetadataItem,
|
||||
primaryAxisSubFieldName,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
}: TransformOneDimensionalGroupByToBarChartDataParams): TransformOneDimensionalGroupByToBarChartDataResult => {
|
||||
const indexByKey = getFieldKey({
|
||||
field: groupByFieldX,
|
||||
@@ -67,6 +71,8 @@ export const transformOneDimensionalGroupByToBarChartData = ({
|
||||
primaryAxisDateGranularity:
|
||||
configuration.primaryAxisDateGranularity ?? undefined,
|
||||
primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
const formattedToRawLookup = buildFormattedToRawLookup(formattedValues);
|
||||
@@ -82,6 +88,8 @@ export const transformOneDimensionalGroupByToBarChartData = ({
|
||||
configuration.primaryAxisDateGranularity ?? undefined,
|
||||
subFieldName:
|
||||
configuration.primaryAxisGroupBySubFieldName ?? undefined,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
})
|
||||
: '';
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/
|
||||
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
|
||||
import { getSortedKeys } from '@/page-layout/widgets/graph/utils/getSortedKeys';
|
||||
import { type BarDatum } from '@nivo/bar';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined, type FirstDayOfTheWeek } from 'twenty-shared/utils';
|
||||
import {
|
||||
BarChartGroupMode,
|
||||
type BarChartConfiguration,
|
||||
@@ -30,6 +30,8 @@ type TransformTwoDimensionalGroupByToBarChartDataParams = {
|
||||
aggregateOperation: string;
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
primaryAxisSubFieldName?: string | null;
|
||||
userTimezone: string;
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek;
|
||||
};
|
||||
|
||||
type TransformTwoDimensionalGroupByToBarChartDataResult = {
|
||||
@@ -50,6 +52,8 @@ export const transformTwoDimensionalGroupByToBarChartData = ({
|
||||
aggregateOperation,
|
||||
objectMetadataItem,
|
||||
primaryAxisSubFieldName,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
}: TransformTwoDimensionalGroupByToBarChartDataParams): TransformTwoDimensionalGroupByToBarChartDataResult => {
|
||||
const indexByKey = getFieldKey({
|
||||
field: groupByFieldX,
|
||||
@@ -65,6 +69,8 @@ export const transformTwoDimensionalGroupByToBarChartData = ({
|
||||
primaryAxisDateGranularity:
|
||||
configuration.primaryAxisDateGranularity ?? undefined,
|
||||
primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
const formattedToRawLookup = buildFormattedToRawLookup(formattedValues);
|
||||
|
||||
@@ -79,13 +85,18 @@ export const transformTwoDimensionalGroupByToBarChartData = ({
|
||||
fieldMetadata: groupByFieldX,
|
||||
dateGranularity: configuration.primaryAxisDateGranularity ?? undefined,
|
||||
subFieldName: configuration.primaryAxisGroupBySubFieldName ?? undefined,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
const yValue = formatDimensionValue({
|
||||
value: dimensionValues[1],
|
||||
fieldMetadata: groupByFieldY,
|
||||
dateGranularity:
|
||||
configuration.secondaryAxisGroupByDateGranularity ?? undefined,
|
||||
subFieldName: configuration.secondaryAxisGroupBySubFieldName ?? undefined,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
if (isDefined(dimensionValues[0])) {
|
||||
|
||||
@@ -8,6 +8,8 @@ import { assertLineChartWidgetOrThrow } from '@/page-layout/widgets/graph/utils/
|
||||
import { buildChartDrilldownQueryParams } from '@/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams';
|
||||
import { generateChartAggregateFilterKey } from '@/page-layout/widgets/graph/utils/generateChartAggregateFilterKey';
|
||||
import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget';
|
||||
import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { coreIndexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreIndexViewIdFromObjectMetadataItemFamilySelector';
|
||||
import { type LineSeries, type Point } from '@nivo/line';
|
||||
@@ -30,6 +32,8 @@ export const GraphWidgetLineChartRenderer = () => {
|
||||
|
||||
assertLineChartWidgetOrThrow(widget);
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const {
|
||||
series,
|
||||
xAxisLabel,
|
||||
@@ -73,6 +77,8 @@ export const GraphWidgetLineChartRenderer = () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek();
|
||||
|
||||
const handlePointClick = (point: Point<LineSeries>) => {
|
||||
const xValue = (point.data as LineChartDataPoint).x;
|
||||
const rawValue = formattedToRawLookup.get(xValue as string) ?? null;
|
||||
@@ -84,7 +90,8 @@ export const GraphWidgetLineChartRenderer = () => {
|
||||
primaryBucketRawValue: rawValue,
|
||||
},
|
||||
viewId: indexViewId,
|
||||
timezone: configuration.timezone ?? undefined,
|
||||
timezone: userTimezone,
|
||||
firstDayOfTheWeek: userFirstDayOfTheWeek,
|
||||
});
|
||||
|
||||
const url = getAppPath(
|
||||
|
||||
@@ -5,6 +5,8 @@ import { getLineChartQueryLimit } from '@/page-layout/widgets/graph/graphWidgetL
|
||||
import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery';
|
||||
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
|
||||
import { transformGroupByDataToLineChartData } from '@/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData';
|
||||
import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useMemo } from 'react';
|
||||
import { type LineChartConfiguration } from '~/generated/graphql';
|
||||
|
||||
@@ -50,6 +52,9 @@ export const useGraphLineChartWidgetData = ({
|
||||
limit,
|
||||
});
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek();
|
||||
|
||||
const transformedData = useMemo(
|
||||
() =>
|
||||
transformGroupByDataToLineChartData({
|
||||
@@ -58,6 +63,8 @@ export const useGraphLineChartWidgetData = ({
|
||||
objectMetadataItems: objectMetadataItems ?? [],
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: userFirstDayOfTheWeek,
|
||||
}),
|
||||
[
|
||||
groupByData,
|
||||
@@ -65,6 +72,8 @@ export const useGraphLineChartWidgetData = ({
|
||||
objectMetadataItems,
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
userTimezone,
|
||||
userFirstDayOfTheWeek,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPi
|
||||
import { assertPieChartWidgetOrThrow } from '@/page-layout/widgets/graph/utils/assertPieChartWidget';
|
||||
import { buildChartDrilldownQueryParams } from '@/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams';
|
||||
import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget';
|
||||
import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { coreIndexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreIndexViewIdFromObjectMetadataItemFamilySelector';
|
||||
import { lazy, Suspense } from 'react';
|
||||
@@ -27,6 +29,8 @@ export const GraphWidgetPieChartRenderer = () => {
|
||||
|
||||
assertPieChartWidgetOrThrow(widget);
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
@@ -52,6 +56,8 @@ export const GraphWidgetPieChartRenderer = () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek();
|
||||
|
||||
const handleSliceClick = (datum: PieChartDataItem) => {
|
||||
const rawValue = formattedToRawLookup.get(datum.id) ?? null;
|
||||
|
||||
@@ -62,7 +68,8 @@ export const GraphWidgetPieChartRenderer = () => {
|
||||
primaryBucketRawValue: rawValue,
|
||||
},
|
||||
viewId: indexViewId,
|
||||
timezone: widget.configuration.timezone ?? undefined,
|
||||
timezone: userTimezone,
|
||||
firstDayOfTheWeek: userFirstDayOfTheWeek,
|
||||
});
|
||||
|
||||
const url = getAppPath(
|
||||
|
||||
@@ -7,6 +7,8 @@ import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPi
|
||||
import { transformGroupByDataToPieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData';
|
||||
import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery';
|
||||
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
|
||||
import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useMemo } from 'react';
|
||||
import { type PieChartConfiguration } from '~/generated/graphql';
|
||||
|
||||
@@ -48,6 +50,9 @@ export const useGraphPieChartWidgetData = ({
|
||||
PIE_CHART_MAXIMUM_NUMBER_OF_SLICES + EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS,
|
||||
});
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek();
|
||||
|
||||
const transformedData = useMemo(
|
||||
() =>
|
||||
transformGroupByDataToPieChartData({
|
||||
@@ -56,6 +61,8 @@ export const useGraphPieChartWidgetData = ({
|
||||
objectMetadataItems: objectMetadataItems ?? [],
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: userFirstDayOfTheWeek,
|
||||
}),
|
||||
[
|
||||
groupByData,
|
||||
@@ -63,6 +70,8 @@ export const useGraphPieChartWidgetData = ({
|
||||
objectMetadataItems,
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
userTimezone,
|
||||
userFirstDayOfTheWeek,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GRAPH_DEFAULT_COLOR } from '@/page-layout/widgets/graph/constants/GraphDefaultColor.constant';
|
||||
import { PIE_CHART_MAXIMUM_NUMBER_OF_SLICES } from '@/page-layout/widgets/graph/graphWidgetPieChart/constants/PieChartMaximumNumberOfSlices.constant';
|
||||
import { transformGroupByDataToPieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData';
|
||||
import { FirstDayOfTheWeek } from 'twenty-shared/types';
|
||||
import {
|
||||
AggregateOperations,
|
||||
FieldMetadataType,
|
||||
@@ -20,6 +21,7 @@ const { formatPrimaryDimensionValues } = jest.requireMock(
|
||||
) as { formatPrimaryDimensionValues: jest.Mock };
|
||||
|
||||
describe('transformGroupByDataToPieChartData', () => {
|
||||
const userTimezone = 'Europe/Paris';
|
||||
it('keeps null group buckets aligned with their aggregate values', () => {
|
||||
const groupByField = {
|
||||
id: 'group-by-field',
|
||||
@@ -76,6 +78,8 @@ describe('transformGroupByDataToPieChartData', () => {
|
||||
objectMetadataItems,
|
||||
configuration,
|
||||
aggregateOperation: 'COUNT',
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
expect(result.data).toEqual([
|
||||
@@ -143,6 +147,8 @@ describe('transformGroupByDataToPieChartData', () => {
|
||||
objectMetadataItems,
|
||||
configuration,
|
||||
aggregateOperation: 'COUNT',
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
expect(result.showLegend).toBe(false);
|
||||
|
||||
@@ -12,7 +12,10 @@ import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/bui
|
||||
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
|
||||
import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues';
|
||||
import { isRelationNestedFieldDateKind } from '@/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind';
|
||||
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import {
|
||||
type FirstDayOfTheWeek,
|
||||
type ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils';
|
||||
import { type PieChartConfiguration } from '~/generated/graphql';
|
||||
|
||||
@@ -22,6 +25,8 @@ type TransformGroupByDataToPieChartDataParams = {
|
||||
objectMetadataItems: ObjectMetadataItem[];
|
||||
configuration: PieChartConfiguration;
|
||||
aggregateOperation: string;
|
||||
userTimezone: string;
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek;
|
||||
};
|
||||
|
||||
type TransformGroupByDataToPieChartDataResult = {
|
||||
@@ -44,6 +49,8 @@ export const transformGroupByDataToPieChartData = ({
|
||||
objectMetadataItems,
|
||||
configuration,
|
||||
aggregateOperation,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
}: TransformGroupByDataToPieChartDataParams): TransformGroupByDataToPieChartDataResult => {
|
||||
if (!isDefined(groupByData)) {
|
||||
return EMPTY_PIE_CHART_RESULT;
|
||||
@@ -100,6 +107,8 @@ export const transformGroupByDataToPieChartData = ({
|
||||
primaryAxisDateGranularity: dateGranularity,
|
||||
primaryAxisGroupBySubFieldName:
|
||||
configuration.groupBySubFieldName ?? undefined,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
const formattedToRawLookup = buildFormattedToRawLookup(formattedValues);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useGraphWidgetQueryCommon } from '@/page-layout/widgets/graph/hooks/use
|
||||
import { type GroupByChartConfiguration } from '@/page-layout/widgets/graph/types/GroupByChartConfiguration';
|
||||
import { generateGroupByQueryVariablesFromBarOrLineChartConfiguration } from '@/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarOrLineChartConfiguration';
|
||||
import { generateGroupByQueryVariablesFromPieChartConfiguration } from '@/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromPieChartConfiguration';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useQuery } from '@apollo/client';
|
||||
import { useMemo } from 'react';
|
||||
import { DEFAULT_NUMBER_OF_GROUPS_LIMIT } from 'twenty-shared/constants';
|
||||
@@ -34,6 +35,8 @@ export const useGraphWidgetGroupByQuery = ({
|
||||
configuration,
|
||||
});
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
|
||||
if (!isDefined(aggregateField)) {
|
||||
@@ -65,6 +68,7 @@ export const useGraphWidgetGroupByQuery = ({
|
||||
aggregateOperation: aggregateOperation,
|
||||
limit,
|
||||
firstDayOfTheWeek: calendarStartDay,
|
||||
userTimeZone: userTimezone,
|
||||
})
|
||||
: generateGroupByQueryVariablesFromBarOrLineChartConfiguration({
|
||||
objectMetadataItem,
|
||||
@@ -75,6 +79,7 @@ export const useGraphWidgetGroupByQuery = ({
|
||||
aggregateOperation: aggregateOperation,
|
||||
limit,
|
||||
firstDayOfTheWeek: calendarStartDay,
|
||||
userTimeZone: userTimezone,
|
||||
});
|
||||
|
||||
const variables = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import {
|
||||
computeRecordGqlOperationFilter,
|
||||
isDefined,
|
||||
@@ -35,9 +36,13 @@ export const useGraphWidgetQueryCommon = ({
|
||||
throw new Error('Aggregate field not found');
|
||||
}
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const gqlOperationFilter = computeRecordGqlOperationFilter({
|
||||
fields: objectMetadataItem.fields,
|
||||
filterValueDependencies: {},
|
||||
filterValueDependencies: {
|
||||
timeZone: userTimezone,
|
||||
},
|
||||
recordFilters: configuration.filter?.recordFilters ?? [],
|
||||
recordFilterGroups: configuration.filter?.recordFilterGroups ?? [],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
|
||||
import { type FirstDayOfTheWeek } from 'twenty-shared/types';
|
||||
import {
|
||||
type BarChartConfiguration,
|
||||
type LineChartConfiguration,
|
||||
@@ -17,4 +18,5 @@ export type BuildChartDrilldownQueryParamsInput = {
|
||||
};
|
||||
viewId?: string;
|
||||
timezone?: string;
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek;
|
||||
};
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-01-15T00:00:00.000Z 1`] = `"Q1 2024"`;
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-01-15 1`] = `"Q1 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-04-15T00:00:00.000Z 1`] = `"Q2 2024"`;
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-02-15 1`] = `"Q1 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-07-15T00:00:00.000Z 1`] = `"Q3 2024"`;
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-03-15 1`] = `"Q1 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-10-15T00:00:00.000Z 1`] = `"Q4 2024"`;
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-04-15 1`] = `"Q2 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity should format date for DAY granularity 1`] = `"Mar 20, 2024"`;
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-05-15 1`] = `"Q2 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity should format date for MONTH granularity 1`] = `"March 2024"`;
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-06-15 1`] = `"Q2 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity should format date for NONE granularity 1`] = `"3/20/2024"`;
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-07-15 1`] = `"Q3 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity should format date for QUARTER granularity 1`] = `"Q1 2024"`;
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-08-15 1`] = `"Q3 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity should format date for WEEK granularity 1`] = `"Mar 20, 2024 20 - 26, 2024"`;
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-09-15 1`] = `"Q3 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity should format date for YEAR granularity 1`] = `"2024"`;
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-10-15 1`] = `"Q4 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-11-15 1`] = `"Q4 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-12-15 1`] = `"Q4 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity should format date for DAY granularity for 2024-03-20 1`] = `"Mar 20, 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity should format date for MONTH granularity for 2024-03-20 1`] = `"March 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity should format date for NONE granularity for 2024-03-20 1`] = `"3/20/2024"`;
|
||||
|
||||
exports[`formatDateByGranularity should format date for QUARTER granularity for 2024-03-20 1`] = `"Q1 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity should format date for WEEK granularity for 2024-03-20 1`] = `"Mar 18 - 24, 2024"`;
|
||||
|
||||
exports[`formatDateByGranularity should format date for YEAR granularity for 2024-03-20 1`] = `"2024"`;
|
||||
|
||||
@@ -61,6 +61,7 @@ exports[`generateGroupByQueryVariablesFromBarOrLineChartConfiguration Bar Chart
|
||||
{
|
||||
"createdAt": {
|
||||
"granularity": "MONTH",
|
||||
"timeZone": "Europe/Paris",
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -136,6 +137,7 @@ exports[`generateGroupByQueryVariablesFromBarOrLineChartConfiguration Line Chart
|
||||
{
|
||||
"createdAt": {
|
||||
"granularity": "MONTH",
|
||||
"timeZone": "Europe/Paris",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`generateGroupByQueryVariablesFromPieChartConfiguration Basic Configuration should generate variables with single groupBy field 1`] = `
|
||||
{
|
||||
"groupBy": [
|
||||
{
|
||||
"stage": true,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`generateGroupByQueryVariablesFromPieChartConfiguration Basic Configuration should generate variables with composite field 1`] = `
|
||||
{
|
||||
"groupBy": [
|
||||
@@ -28,6 +18,7 @@ exports[`generateGroupByQueryVariablesFromPieChartConfiguration Basic Configurat
|
||||
{
|
||||
"createdAt": {
|
||||
"granularity": "MONTH",
|
||||
"timeZone": "Europe/Paris",
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -62,3 +53,12 @@ exports[`generateGroupByQueryVariablesFromPieChartConfiguration Basic Configurat
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`generateGroupByQueryVariablesFromPieChartConfiguration Basic Configuration should generate variables with single groupBy field 1`] = `
|
||||
{
|
||||
"groupBy": [
|
||||
{
|
||||
"stage": true,
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import { buildDateFilterForDayGranularity } from '../buildDateFilterForDayGranularity';
|
||||
@@ -5,7 +6,10 @@ import { buildDateFilterForDayGranularity } from '../buildDateFilterForDayGranul
|
||||
describe('buildDateFilterForDayGranularity', () => {
|
||||
describe('DATE field type', () => {
|
||||
it('should return single IS filter with plain date for DATE field', () => {
|
||||
const date = new Date('2024-03-15T10:00:00Z');
|
||||
const date = Temporal.PlainDate.from('2024-03-15').toZonedDateTime({
|
||||
timeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
const result = buildDateFilterForDayGranularity(
|
||||
date,
|
||||
FieldMetadataType.DATE,
|
||||
@@ -15,13 +19,16 @@ describe('buildDateFilterForDayGranularity', () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].fieldName).toBe('createdAt');
|
||||
expect(result[0].operand).toBe(ViewFilterOperand.IS);
|
||||
expect(result[0].value).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(result[0].value).toMatch('2024-03-15');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DATE_TIME field type', () => {
|
||||
it('should return IS_AFTER and IS_BEFORE filters for DATE_TIME field', () => {
|
||||
const date = new Date('2024-03-15T10:00:00Z');
|
||||
const date = Temporal.PlainDate.from('2024-03-15').toZonedDateTime({
|
||||
timeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
const result = buildDateFilterForDayGranularity(
|
||||
date,
|
||||
FieldMetadataType.DATE_TIME,
|
||||
@@ -31,30 +38,53 @@ describe('buildDateFilterForDayGranularity', () => {
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER);
|
||||
expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE);
|
||||
expect(result[0].value).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(result[1].value).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(result[0].value).toMatch('2024-03-15T00:00:00+01:00');
|
||||
expect(result[1].value).toMatch('2024-03-16T00:00:00+01:00');
|
||||
});
|
||||
|
||||
it('should apply timezone for DATE_TIME field when provided', () => {
|
||||
const date = new Date('2024-03-15T10:00:00Z');
|
||||
const timezone = 'America/New_York';
|
||||
it('should handle extreme timezone for DATE_TIME - Auckland GMT+13', () => {
|
||||
const date = Temporal.PlainDate.from('2024-03-15').toZonedDateTime({
|
||||
timeZone: 'Pacific/Auckland',
|
||||
});
|
||||
|
||||
const result = buildDateFilterForDayGranularity(
|
||||
date,
|
||||
FieldMetadataType.DATE_TIME,
|
||||
'createdAt',
|
||||
timezone,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER);
|
||||
expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE);
|
||||
expect(result[0].value).toMatch('2024-03-15T00:00:00+13:00');
|
||||
expect(result[1].value).toMatch('2024-03-16T00:00:00+13:00');
|
||||
});
|
||||
|
||||
it('should handle extreme timezone for DATE_TIME - Samoa GMT-11', () => {
|
||||
const date = Temporal.PlainDate.from('2024-03-15').toZonedDateTime({
|
||||
timeZone: 'Pacific/Samoa',
|
||||
});
|
||||
|
||||
const result = buildDateFilterForDayGranularity(
|
||||
date,
|
||||
FieldMetadataType.DATE_TIME,
|
||||
'createdAt',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER);
|
||||
expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE);
|
||||
expect(result[0].value).toMatch('2024-03-15T00:00:00-11:00');
|
||||
expect(result[1].value).toMatch('2024-03-16T00:00:00-11:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsupported field types', () => {
|
||||
it('should return empty array for non-date field types', () => {
|
||||
const date = new Date('2024-03-15T10:00:00Z');
|
||||
const date = Temporal.Instant.from(
|
||||
'2024-03-15T10:00:00Z',
|
||||
).toZonedDateTimeISO('Europe/Paris');
|
||||
|
||||
const result = buildDateFilterForDayGranularity(
|
||||
date,
|
||||
FieldMetadataType.TEXT as FieldMetadataType.DATE,
|
||||
@@ -67,7 +97,10 @@ describe('buildDateFilterForDayGranularity', () => {
|
||||
|
||||
describe('field name handling', () => {
|
||||
it('should use provided fieldName in filters', () => {
|
||||
const date = new Date('2024-03-15');
|
||||
const date = Temporal.PlainDate.from('2024-03-15').toZonedDateTime({
|
||||
timeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
const result = buildDateFilterForDayGranularity(
|
||||
date,
|
||||
FieldMetadataType.DATE,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import {
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
ViewFilterOperand,
|
||||
@@ -5,10 +6,13 @@ import {
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import { buildDateRangeFiltersForGranularity } from '../buildDateRangeFiltersForGranularity';
|
||||
|
||||
const testDate = Temporal.PlainDate.from('2024-03-15').toZonedDateTime({
|
||||
timeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
describe('buildDateRangeFiltersForGranularity', () => {
|
||||
describe('WEEK granularity', () => {
|
||||
it('should return IS_AFTER and IS_BEFORE filters for DATE field', () => {
|
||||
const testDate = new Date('2024-03-15');
|
||||
const result = buildDateRangeFiltersForGranularity(
|
||||
testDate,
|
||||
ObjectRecordGroupByDateGranularity.WEEK,
|
||||
@@ -22,7 +26,6 @@ describe('buildDateRangeFiltersForGranularity', () => {
|
||||
});
|
||||
|
||||
it('should return ISO strings for DATE_TIME field', () => {
|
||||
const testDate = new Date('2024-03-15T10:00:00Z');
|
||||
const result = buildDateRangeFiltersForGranularity(
|
||||
testDate,
|
||||
ObjectRecordGroupByDateGranularity.WEEK,
|
||||
@@ -38,7 +41,6 @@ describe('buildDateRangeFiltersForGranularity', () => {
|
||||
|
||||
describe('MONTH granularity', () => {
|
||||
it('should return IS_AFTER and IS_BEFORE filters for DATE field', () => {
|
||||
const testDate = new Date('2024-03-15');
|
||||
const result = buildDateRangeFiltersForGranularity(
|
||||
testDate,
|
||||
ObjectRecordGroupByDateGranularity.MONTH,
|
||||
@@ -52,7 +54,6 @@ describe('buildDateRangeFiltersForGranularity', () => {
|
||||
});
|
||||
|
||||
it('should return ISO strings for DATE_TIME field without timezone', () => {
|
||||
const testDate = new Date('2024-03-15T10:00:00Z');
|
||||
const result = buildDateRangeFiltersForGranularity(
|
||||
testDate,
|
||||
ObjectRecordGroupByDateGranularity.MONTH,
|
||||
@@ -66,31 +67,18 @@ describe('buildDateRangeFiltersForGranularity', () => {
|
||||
expect(result[0].value).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(result[1].value).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it('should apply timezone for DATE_TIME field when provided', () => {
|
||||
const testDate = new Date('2024-03-15T10:00:00Z');
|
||||
const timezone = 'America/New_York';
|
||||
|
||||
const result = buildDateRangeFiltersForGranularity(
|
||||
testDate,
|
||||
ObjectRecordGroupByDateGranularity.MONTH,
|
||||
FieldMetadataType.DATE_TIME,
|
||||
'createdAt',
|
||||
timezone,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER);
|
||||
expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QUARTER granularity', () => {
|
||||
it('should return filters for Q1 date', () => {
|
||||
const testDate = new Date('2024-02-15');
|
||||
const firstQuarterDateTime = Temporal.PlainDate.from(
|
||||
'2024-02-15',
|
||||
).toZonedDateTime({
|
||||
timeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
const result = buildDateRangeFiltersForGranularity(
|
||||
testDate,
|
||||
firstQuarterDateTime,
|
||||
ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
FieldMetadataType.DATE,
|
||||
'createdAt',
|
||||
@@ -102,8 +90,6 @@ describe('buildDateRangeFiltersForGranularity', () => {
|
||||
});
|
||||
|
||||
it('should return ISO strings for DATE_TIME field', () => {
|
||||
const testDate = new Date('2024-02-15');
|
||||
|
||||
const result = buildDateRangeFiltersForGranularity(
|
||||
testDate,
|
||||
ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
@@ -121,10 +107,14 @@ describe('buildDateRangeFiltersForGranularity', () => {
|
||||
|
||||
describe('YEAR granularity', () => {
|
||||
it('should return start and end of year for DATE field', () => {
|
||||
const testDate = new Date('2024-06-15');
|
||||
const midYearDateTime = Temporal.PlainDate.from(
|
||||
'2024-06-15',
|
||||
).toZonedDateTime({
|
||||
timeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
const result = buildDateRangeFiltersForGranularity(
|
||||
testDate,
|
||||
midYearDateTime,
|
||||
ObjectRecordGroupByDateGranularity.YEAR,
|
||||
FieldMetadataType.DATE,
|
||||
'createdAt',
|
||||
@@ -136,10 +126,14 @@ describe('buildDateRangeFiltersForGranularity', () => {
|
||||
});
|
||||
|
||||
it('should return ISO strings for DATE_TIME field', () => {
|
||||
const testDate = new Date('2024-06-15T10:00:00Z');
|
||||
const midYearDateTime = Temporal.PlainDate.from(
|
||||
'2024-06-15',
|
||||
).toZonedDateTime({
|
||||
timeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
const result = buildDateRangeFiltersForGranularity(
|
||||
testDate,
|
||||
midYearDateTime,
|
||||
ObjectRecordGroupByDateGranularity.YEAR,
|
||||
FieldMetadataType.DATE_TIME,
|
||||
'createdAt',
|
||||
@@ -147,31 +141,12 @@ describe('buildDateRangeFiltersForGranularity', () => {
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].value).toMatch(/^2024-01-01T/);
|
||||
expect(result[1].value).toMatch(/^2024-12-31T/);
|
||||
});
|
||||
|
||||
it('should apply timezone for DATE_TIME field', () => {
|
||||
const testDate = new Date('2024-06-15T10:00:00Z');
|
||||
const timezone = 'Asia/Tokyo';
|
||||
|
||||
const result = buildDateRangeFiltersForGranularity(
|
||||
testDate,
|
||||
ObjectRecordGroupByDateGranularity.YEAR,
|
||||
FieldMetadataType.DATE_TIME,
|
||||
'createdAt',
|
||||
timezone,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER);
|
||||
expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE);
|
||||
expect(result[1].value).toMatch(/^2025-01-01T/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Field name handling', () => {
|
||||
it('should use provided fieldName in filters', () => {
|
||||
const testDate = new Date('2024-03-15');
|
||||
|
||||
const result = buildDateRangeFiltersForGranularity(
|
||||
testDate,
|
||||
ObjectRecordGroupByDateGranularity.MONTH,
|
||||
@@ -184,8 +159,6 @@ describe('buildDateRangeFiltersForGranularity', () => {
|
||||
});
|
||||
|
||||
it('should handle subField-style fieldName', () => {
|
||||
const testDate = new Date('2024-03-15');
|
||||
|
||||
const result = buildDateRangeFiltersForGranularity(
|
||||
testDate,
|
||||
ObjectRecordGroupByDateGranularity.MONTH,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { buildGroupByFieldObject } from '@/page-layout/widgets/graph/utils/buildGroupByFieldObject';
|
||||
import { CalendarStartDay } from 'twenty-shared';
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
const userTimezone = 'Europe/Paris';
|
||||
|
||||
describe('buildGroupByFieldObject', () => {
|
||||
it('should return field with Id suffix for relation fields without subFieldName', () => {
|
||||
const field = {
|
||||
@@ -53,11 +55,15 @@ describe('buildGroupByFieldObject', () => {
|
||||
subFieldName: 'createdAt',
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
isNestedDateField: true,
|
||||
timeZone: userTimezone,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
company: {
|
||||
createdAt: { granularity: ObjectRecordGroupByDateGranularity.MONTH },
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
timeZone: 'Europe/Paris',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -97,11 +103,12 @@ describe('buildGroupByFieldObject', () => {
|
||||
type: FieldMetadataType.DATE,
|
||||
} as any;
|
||||
|
||||
const result = buildGroupByFieldObject({ field });
|
||||
const result = buildGroupByFieldObject({ field, timeZone: 'Europe/Paris' });
|
||||
|
||||
expect(result).toEqual({
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
timeZone: 'Europe/Paris',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -112,11 +119,12 @@ describe('buildGroupByFieldObject', () => {
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
} as any;
|
||||
|
||||
const result = buildGroupByFieldObject({ field });
|
||||
const result = buildGroupByFieldObject({ field, timeZone: 'Europe/Paris' });
|
||||
|
||||
expect(result).toEqual({
|
||||
updatedAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
timeZone: 'Europe/Paris',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -130,11 +138,13 @@ describe('buildGroupByFieldObject', () => {
|
||||
const result = buildGroupByFieldObject({
|
||||
field,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
timeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
timeZone: 'Europe/Paris',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -160,12 +170,14 @@ describe('buildGroupByFieldObject', () => {
|
||||
field,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
firstDayOfTheWeek: CalendarStartDay.MONDAY,
|
||||
timeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
weekStartDay: 'MONDAY',
|
||||
timeZone: 'Europe/Paris',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -180,12 +192,14 @@ describe('buildGroupByFieldObject', () => {
|
||||
field,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
firstDayOfTheWeek: CalendarStartDay.SUNDAY,
|
||||
timeZone: userTimezone,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
weekStartDay: 'SUNDAY',
|
||||
timeZone: userTimezone,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -200,11 +214,13 @@ describe('buildGroupByFieldObject', () => {
|
||||
field,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
firstDayOfTheWeek: CalendarStartDay.SYSTEM,
|
||||
timeZone: userTimezone,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.WEEK,
|
||||
timeZone: userTimezone,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -219,11 +235,13 @@ describe('buildGroupByFieldObject', () => {
|
||||
field,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
firstDayOfTheWeek: CalendarStartDay.MONDAY,
|
||||
timeZone: userTimezone,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
timeZone: userTimezone,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { calculateQuarterDateRange } from '../calculateQuarterDateRange';
|
||||
|
||||
describe('calculateQuarterDateRange', () => {
|
||||
describe('Q1 (January - March)', () => {
|
||||
it('should return Q1 range for January date', () => {
|
||||
const date = new Date('2024-01-15');
|
||||
const result = calculateQuarterDateRange(date);
|
||||
|
||||
expect(result.rangeStartDate.getFullYear()).toBe(2024);
|
||||
expect(result.rangeStartDate.getMonth()).toBe(0);
|
||||
expect(result.rangeStartDate.getDate()).toBe(1);
|
||||
|
||||
expect(result.rangeEndDate.getFullYear()).toBe(2024);
|
||||
expect(result.rangeEndDate.getMonth()).toBe(2);
|
||||
expect(result.rangeEndDate.getDate()).toBe(31);
|
||||
});
|
||||
|
||||
it('should return Q1 range for March date', () => {
|
||||
const date = new Date('2024-03-20');
|
||||
const result = calculateQuarterDateRange(date);
|
||||
|
||||
expect(result.rangeStartDate.getMonth()).toBe(0);
|
||||
expect(result.rangeEndDate.getMonth()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Q2 (April - June)', () => {
|
||||
it('should return Q2 range for April date', () => {
|
||||
const date = new Date('2024-04-15');
|
||||
const result = calculateQuarterDateRange(date);
|
||||
|
||||
expect(result.rangeStartDate.getMonth()).toBe(3);
|
||||
expect(result.rangeStartDate.getDate()).toBe(1);
|
||||
expect(result.rangeEndDate.getMonth()).toBe(5);
|
||||
expect(result.rangeEndDate.getDate()).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Q3 (July - September)', () => {
|
||||
it('should return Q3 range for August date', () => {
|
||||
const date = new Date('2024-08-15');
|
||||
const result = calculateQuarterDateRange(date);
|
||||
|
||||
expect(result.rangeStartDate.getMonth()).toBe(6);
|
||||
expect(result.rangeStartDate.getDate()).toBe(1);
|
||||
expect(result.rangeEndDate.getMonth()).toBe(8);
|
||||
expect(result.rangeEndDate.getDate()).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Q4 (October - December)', () => {
|
||||
it('should return Q4 range for November date', () => {
|
||||
const date = new Date('2024-11-15');
|
||||
const result = calculateQuarterDateRange(date);
|
||||
|
||||
expect(result.rangeStartDate.getMonth()).toBe(9);
|
||||
expect(result.rangeStartDate.getDate()).toBe(1);
|
||||
expect(result.rangeEndDate.getMonth()).toBe(11);
|
||||
expect(result.rangeEndDate.getDate()).toBe(31);
|
||||
});
|
||||
});
|
||||
|
||||
describe('year boundary', () => {
|
||||
it('should handle year correctly for Q1', () => {
|
||||
const date = new Date('2025-02-15');
|
||||
const result = calculateQuarterDateRange(date);
|
||||
|
||||
expect(result.rangeStartDate.getFullYear()).toBe(2025);
|
||||
expect(result.rangeEndDate.getFullYear()).toBe(2025);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,13 @@
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import {
|
||||
FirstDayOfTheWeek,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
import { formatDateByGranularity } from '../formatDateByGranularity';
|
||||
|
||||
describe('formatDateByGranularity', () => {
|
||||
const testDate = new Date('2024-03-20T12:00:00Z');
|
||||
|
||||
beforeAll(() => {
|
||||
// Mock toLocaleDateString to avoid timezone/locale issues
|
||||
jest
|
||||
.spyOn(Date.prototype, 'toLocaleDateString')
|
||||
.mockImplementation((_locales, options) => {
|
||||
if (options?.weekday === 'long') return 'Wednesday';
|
||||
if (options?.month === 'long' && !isDefined(options?.year))
|
||||
return 'March';
|
||||
if (options?.month === 'long' && isDefined(options?.year))
|
||||
return 'March 2024';
|
||||
if (options?.month === 'short') return 'Mar 20, 2024';
|
||||
return '3/20/2024';
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
const testDate = Temporal.PlainDate.from('2024-03-20');
|
||||
const userTimezone = 'Europe/Paris';
|
||||
|
||||
const testCases: {
|
||||
granularity:
|
||||
@@ -42,54 +27,49 @@ describe('formatDateByGranularity', () => {
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
'should format date for $granularity granularity',
|
||||
`should format date for $granularity granularity for ${testDate.toString()}`,
|
||||
({ granularity }) => {
|
||||
expect(formatDateByGranularity(testDate, granularity)).toMatchSnapshot();
|
||||
expect(
|
||||
formatDateByGranularity(
|
||||
testDate,
|
||||
granularity,
|
||||
userTimezone,
|
||||
FirstDayOfTheWeek.MONDAY,
|
||||
),
|
||||
).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
|
||||
describe('week calculations', () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest
|
||||
.spyOn(Date.prototype, 'toLocaleDateString')
|
||||
.mockImplementation((_locales, options) => {
|
||||
if (options?.weekday === 'long') return 'Wednesday';
|
||||
if (options?.month === 'long' && !isDefined(options?.year))
|
||||
return 'March';
|
||||
if (options?.month === 'long' && isDefined(options?.year))
|
||||
return 'March 2024';
|
||||
if (options?.month === 'short') return 'Mar 20, 2024';
|
||||
return '3/20/2024';
|
||||
});
|
||||
});
|
||||
|
||||
it('should format week within same month', () => {
|
||||
const date = new Date('2024-05-06');
|
||||
const date = Temporal.PlainDate.from('2024-05-06');
|
||||
const result = formatDateByGranularity(
|
||||
date,
|
||||
ObjectRecordGroupByDateGranularity.WEEK,
|
||||
userTimezone,
|
||||
FirstDayOfTheWeek.MONDAY,
|
||||
);
|
||||
expect(result).toBe('May 6 - 12, 2024');
|
||||
});
|
||||
|
||||
it('should format week crossing months', () => {
|
||||
const date = new Date('2024-05-27');
|
||||
const date = Temporal.PlainDate.from('2024-05-27');
|
||||
const result = formatDateByGranularity(
|
||||
date,
|
||||
ObjectRecordGroupByDateGranularity.WEEK,
|
||||
userTimezone,
|
||||
FirstDayOfTheWeek.MONDAY,
|
||||
);
|
||||
expect(result).toBe('May 27 - Jun 2, 2024');
|
||||
});
|
||||
|
||||
it('should format week crossing years', () => {
|
||||
const date = new Date('2024-12-30');
|
||||
const date = Temporal.PlainDate.from('2024-12-30');
|
||||
const result = formatDateByGranularity(
|
||||
date,
|
||||
ObjectRecordGroupByDateGranularity.WEEK,
|
||||
userTimezone,
|
||||
FirstDayOfTheWeek.MONDAY,
|
||||
);
|
||||
expect(result).toBe('Dec 30, 2024 - Jan 5, 2025');
|
||||
});
|
||||
@@ -97,31 +77,70 @@ describe('formatDateByGranularity', () => {
|
||||
|
||||
describe('quarter calculations', () => {
|
||||
const quarterTestCases: {
|
||||
date: Date;
|
||||
dayString: string;
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER;
|
||||
}[] = [
|
||||
{
|
||||
date: new Date('2024-01-15'),
|
||||
dayString: '2024-01-15',
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
},
|
||||
{
|
||||
date: new Date('2024-04-15'),
|
||||
dayString: '2024-02-15',
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
},
|
||||
{
|
||||
date: new Date('2024-07-15'),
|
||||
dayString: '2024-03-15',
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
},
|
||||
{
|
||||
date: new Date('2024-10-15'),
|
||||
dayString: '2024-04-15',
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
},
|
||||
{
|
||||
dayString: '2024-05-15',
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
},
|
||||
{
|
||||
dayString: '2024-06-15',
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
},
|
||||
{
|
||||
dayString: '2024-07-15',
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
},
|
||||
{
|
||||
dayString: '2024-08-15',
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
},
|
||||
{
|
||||
dayString: '2024-09-15',
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
},
|
||||
{
|
||||
dayString: '2024-10-15',
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
},
|
||||
{
|
||||
dayString: '2024-11-15',
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
},
|
||||
{
|
||||
dayString: '2024-12-15',
|
||||
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(quarterTestCases)(
|
||||
'should calculate quarter for $granularity with date $date',
|
||||
({ date, granularity }) => {
|
||||
expect(formatDateByGranularity(date, granularity)).toMatchSnapshot();
|
||||
'should calculate quarter for $dayString',
|
||||
({ dayString, granularity }) => {
|
||||
expect(
|
||||
formatDateByGranularity(
|
||||
Temporal.PlainDate.from(dayString),
|
||||
granularity,
|
||||
'Europe/Paris',
|
||||
FirstDayOfTheWeek.MONDAY,
|
||||
),
|
||||
).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
FirstDayOfTheWeek,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
@@ -14,6 +15,8 @@ const { formatDimensionValue } = jest.requireMock(
|
||||
'@/page-layout/widgets/graph/utils/formatDimensionValue',
|
||||
) as { formatDimensionValue: jest.Mock };
|
||||
|
||||
const userTimezone = 'Europe/Paris';
|
||||
|
||||
describe('formatPrimaryDimensionValues', () => {
|
||||
it('includes buckets where the primary dimension value is null', () => {
|
||||
const result = formatPrimaryDimensionValues({
|
||||
@@ -25,6 +28,8 @@ describe('formatPrimaryDimensionValues', () => {
|
||||
name: 'status',
|
||||
type: FieldMetadataType.TEXT,
|
||||
} as any,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
@@ -46,13 +51,20 @@ describe('formatPrimaryDimensionValues', () => {
|
||||
} as any,
|
||||
primaryAxisDateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
primaryAxisGroupBySubFieldName: 'createdAt',
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
expect(formatDimensionValue).toHaveBeenCalledWith({
|
||||
value: '2024-01-15T00:00:00.000Z',
|
||||
fieldMetadata: expect.objectContaining({ name: 'createdAt' }),
|
||||
fieldMetadata: expect.objectContaining({
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
}),
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
|
||||
subFieldName: 'createdAt',
|
||||
userTimezone: 'Europe/Paris',
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +124,7 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => {
|
||||
primaryAxisGroupBySubFieldName: null,
|
||||
primaryAxisDateGranularity: 'MONTH' as any,
|
||||
}),
|
||||
userTimeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
@@ -199,11 +200,15 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => {
|
||||
primaryAxisGroupByFieldMetadataId: relationField.id,
|
||||
primaryAxisGroupBySubFieldName: 'createdAt',
|
||||
}),
|
||||
userTimeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
expect(result.groupBy[0]).toEqual({
|
||||
company: {
|
||||
createdAt: { granularity: ObjectRecordGroupByDateGranularity.DAY },
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
timeZone: 'Europe/Paris',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -245,11 +250,15 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => {
|
||||
primaryAxisGroupByFieldMetadataId: relationField.id,
|
||||
primaryAxisGroupBySubFieldName: 'createdAt',
|
||||
}),
|
||||
userTimeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
expect(result.groupBy[0]).toEqual({
|
||||
company: {
|
||||
createdAt: { granularity: ObjectRecordGroupByDateGranularity.DAY },
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
timeZone: 'Europe/Paris',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -299,6 +308,7 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => {
|
||||
primaryAxisGroupBySubFieldName: null,
|
||||
primaryAxisDateGranularity: 'MONTH' as any,
|
||||
}),
|
||||
userTimeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import {
|
||||
AggregateOperations,
|
||||
FieldMetadataType,
|
||||
@@ -6,7 +7,6 @@ import {
|
||||
GraphType,
|
||||
type PieChartConfiguration,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import { generateGroupByQueryVariablesFromPieChartConfiguration } from '../generateGroupByQueryVariablesFromPieChartConfiguration';
|
||||
|
||||
describe('generateGroupByQueryVariablesFromPieChartConfiguration', () => {
|
||||
@@ -86,6 +86,7 @@ describe('generateGroupByQueryVariablesFromPieChartConfiguration', () => {
|
||||
groupBySubFieldName: null,
|
||||
dateGranularity: 'MONTH' as any,
|
||||
}),
|
||||
userTimeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
@@ -157,11 +158,15 @@ describe('generateGroupByQueryVariablesFromPieChartConfiguration', () => {
|
||||
groupByFieldMetadataId: relationField.id,
|
||||
groupBySubFieldName: 'createdAt',
|
||||
}),
|
||||
userTimeZone: 'Europe/Paris',
|
||||
});
|
||||
|
||||
expect(result.groupBy[0]).toEqual({
|
||||
company: {
|
||||
createdAt: { granularity: ObjectRecordGroupByDateGranularity.DAY },
|
||||
createdAt: {
|
||||
granularity: ObjectRecordGroupByDateGranularity.DAY,
|
||||
timeZone: 'Europe/Paris',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
|
||||
import { FirstDayOfTheWeek } from 'twenty-shared/types';
|
||||
import {
|
||||
AggregateOperations,
|
||||
FieldMetadataType,
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
import { transformOneDimensionalGroupByToLineChartData } from '../transformOneDimensionalGroupByToLineChartData';
|
||||
|
||||
describe('transformOneDimensionalGroupByToLineChartData', () => {
|
||||
const userTimezone = 'Europe/Paris';
|
||||
|
||||
const mockAggregateField: FieldMetadataItem = {
|
||||
id: 'amount-field',
|
||||
name: 'amount',
|
||||
@@ -76,6 +79,8 @@ describe('transformOneDimensionalGroupByToLineChartData', () => {
|
||||
aggregateOperation: 'sumAmount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
primaryAxisSubFieldName: null,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
expect(result.series).toHaveLength(1);
|
||||
@@ -116,6 +121,8 @@ describe('transformOneDimensionalGroupByToLineChartData', () => {
|
||||
aggregateOperation: 'sumAmount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
primaryAxisSubFieldName: null,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
expect(result.series[0].data).toEqual([
|
||||
@@ -130,15 +137,15 @@ describe('transformOneDimensionalGroupByToLineChartData', () => {
|
||||
it('should transform date-based groupBy results', () => {
|
||||
const rawResults: GroupByRawResult[] = [
|
||||
{
|
||||
groupByDimensionValues: ['2024-01'],
|
||||
groupByDimensionValues: ['2024-01-01'],
|
||||
sumAmount: 50000,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-02'],
|
||||
groupByDimensionValues: ['2024-02-01'],
|
||||
sumAmount: 75000,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-03'],
|
||||
groupByDimensionValues: ['2024-03-01'],
|
||||
sumAmount: 60000,
|
||||
},
|
||||
];
|
||||
@@ -154,6 +161,8 @@ describe('transformOneDimensionalGroupByToLineChartData', () => {
|
||||
aggregateOperation: 'sumAmount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
primaryAxisSubFieldName: null,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
expect(result.series).toHaveLength(1);
|
||||
@@ -173,6 +182,8 @@ describe('transformOneDimensionalGroupByToLineChartData', () => {
|
||||
aggregateOperation: 'sumAmount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
primaryAxisSubFieldName: null,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
expect(result.series).toHaveLength(1);
|
||||
@@ -194,6 +205,8 @@ describe('transformOneDimensionalGroupByToLineChartData', () => {
|
||||
aggregateOperation: 'sumAmount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
primaryAxisSubFieldName: null,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
expect(result.series[0].color).toBeDefined();
|
||||
@@ -222,6 +235,8 @@ describe('transformOneDimensionalGroupByToLineChartData', () => {
|
||||
aggregateOperation: '_count',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
primaryAxisSubFieldName: null,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
expect(result.series[0].data).toEqual([
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
|
||||
import { FirstDayOfTheWeek } from 'twenty-shared/types';
|
||||
import {
|
||||
AggregateOperations,
|
||||
FieldMetadataType,
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
import { transformTwoDimensionalGroupByToLineChartData } from '../transformTwoDimensionalGroupByToLineChartData';
|
||||
|
||||
describe('transformTwoDimensionalGroupByToLineChartData', () => {
|
||||
const userTimezone = 'Europe/Paris';
|
||||
|
||||
const mockAggregateField: FieldMetadataItem = {
|
||||
id: 'amount-field',
|
||||
name: 'amount',
|
||||
@@ -56,27 +59,27 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => {
|
||||
it('should create multiple series from 2D groupBy results', () => {
|
||||
const rawResults: GroupByRawResult[] = [
|
||||
{
|
||||
groupByDimensionValues: ['2024-01', 'Qualification'],
|
||||
groupByDimensionValues: ['2024-01-01', 'Qualification'],
|
||||
sumAmount: 50000,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-01', 'Proposal'],
|
||||
groupByDimensionValues: ['2024-01-01', 'Proposal'],
|
||||
sumAmount: 75000,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-02', 'Qualification'],
|
||||
groupByDimensionValues: ['2024-02-01', 'Qualification'],
|
||||
sumAmount: 60000,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-02', 'Proposal'],
|
||||
groupByDimensionValues: ['2024-02-01', 'Proposal'],
|
||||
sumAmount: 90000,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-03', 'Qualification'],
|
||||
groupByDimensionValues: ['2024-03-01', 'Qualification'],
|
||||
sumAmount: 55000,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-03', 'Proposal'],
|
||||
groupByDimensionValues: ['2024-03-01', 'Proposal'],
|
||||
sumAmount: 80000,
|
||||
},
|
||||
];
|
||||
@@ -90,6 +93,8 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => {
|
||||
aggregateOperation: 'sumAmount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
primaryAxisSubFieldName: null,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
expect(result.series).toHaveLength(2);
|
||||
@@ -119,15 +124,15 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => {
|
||||
it('should preserve backend ordering of data points', () => {
|
||||
const rawResults: GroupByRawResult[] = [
|
||||
{
|
||||
groupByDimensionValues: ['2024-01', 'Stage A'],
|
||||
groupByDimensionValues: ['2024-01-01', 'Stage A'],
|
||||
sumAmount: 100,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-02', 'Stage A'],
|
||||
groupByDimensionValues: ['2024-02-01', 'Stage A'],
|
||||
sumAmount: 200,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-03', 'Stage A'],
|
||||
groupByDimensionValues: ['2024-03-01', 'Stage A'],
|
||||
sumAmount: 300,
|
||||
},
|
||||
];
|
||||
@@ -141,6 +146,8 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => {
|
||||
aggregateOperation: 'sumAmount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
primaryAxisSubFieldName: null,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
const series = result.series[0];
|
||||
@@ -154,19 +161,19 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => {
|
||||
it('should normalize sparse data (all series share same X values, with 0 for missing)', () => {
|
||||
const rawResults: GroupByRawResult[] = [
|
||||
{
|
||||
groupByDimensionValues: ['2024-01', 'Stage A'],
|
||||
groupByDimensionValues: ['2024-01-01', 'Stage A'],
|
||||
sumAmount: 100,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-02', 'Stage A'],
|
||||
groupByDimensionValues: ['2024-02-01', 'Stage A'],
|
||||
sumAmount: 200,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-01', 'Stage B'],
|
||||
groupByDimensionValues: ['2024-01-01', 'Stage B'],
|
||||
sumAmount: 150,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-03', 'Stage B'],
|
||||
groupByDimensionValues: ['2024-03-01', 'Stage B'],
|
||||
sumAmount: 250,
|
||||
},
|
||||
];
|
||||
@@ -180,6 +187,8 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => {
|
||||
aggregateOperation: 'sumAmount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
primaryAxisSubFieldName: null,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
const stageA = result.series.find((s) => s.id === 'Stage A');
|
||||
@@ -200,15 +209,15 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => {
|
||||
it('should filter out null aggregate values', () => {
|
||||
const rawResults: GroupByRawResult[] = [
|
||||
{
|
||||
groupByDimensionValues: ['2024-01', 'Stage A'],
|
||||
groupByDimensionValues: ['2024-01-01', 'Stage A'],
|
||||
sumAmount: 100,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-02', 'Stage A'],
|
||||
groupByDimensionValues: ['2024-02-01', 'Stage A'],
|
||||
sumAmount: null,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-03', 'Stage A'],
|
||||
groupByDimensionValues: ['2024-03-01', 'Stage A'],
|
||||
sumAmount: 200,
|
||||
},
|
||||
];
|
||||
@@ -222,6 +231,8 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => {
|
||||
aggregateOperation: 'sumAmount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
primaryAxisSubFieldName: null,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
expect(result.series[0].data).toHaveLength(2);
|
||||
@@ -242,6 +253,8 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => {
|
||||
aggregateOperation: 'sumAmount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
primaryAxisSubFieldName: null,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
expect(result.series).toEqual([]);
|
||||
@@ -251,11 +264,11 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => {
|
||||
it('should skip results with missing dimension values', () => {
|
||||
const rawResults: GroupByRawResult[] = [
|
||||
{
|
||||
groupByDimensionValues: ['2024-01'],
|
||||
groupByDimensionValues: ['2024-01-01'],
|
||||
sumAmount: 100,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-02', 'Stage A'],
|
||||
groupByDimensionValues: ['2024-02-01', 'Stage A'],
|
||||
sumAmount: 200,
|
||||
},
|
||||
];
|
||||
@@ -269,6 +282,8 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => {
|
||||
aggregateOperation: 'sumAmount',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
primaryAxisSubFieldName: null,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
expect(result.series).toHaveLength(1);
|
||||
@@ -280,11 +295,11 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => {
|
||||
it('should handle COUNT operation', () => {
|
||||
const rawResults: GroupByRawResult[] = [
|
||||
{
|
||||
groupByDimensionValues: ['2024-01', 'Stage A'],
|
||||
groupByDimensionValues: ['2024-01-01', 'Stage A'],
|
||||
_count: 5,
|
||||
},
|
||||
{
|
||||
groupByDimensionValues: ['2024-02', 'Stage A'],
|
||||
groupByDimensionValues: ['2024-02-01', 'Stage A'],
|
||||
_count: 10,
|
||||
},
|
||||
];
|
||||
@@ -300,6 +315,8 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => {
|
||||
aggregateOperation: '_count',
|
||||
objectMetadataItem: mockObjectMetadataItem,
|
||||
primaryAxisSubFieldName: null,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
|
||||
});
|
||||
|
||||
expect(result.series[0].data.map((d) => d.y)).toEqual([5, 10]);
|
||||
|
||||
@@ -11,6 +11,7 @@ export const buildChartDrilldownQueryParams = ({
|
||||
clickedData,
|
||||
viewId,
|
||||
timezone,
|
||||
firstDayOfTheWeek,
|
||||
}: BuildChartDrilldownQueryParamsInput): URLSearchParams => {
|
||||
const drilldownQueryParams = new URLSearchParams();
|
||||
|
||||
@@ -42,6 +43,7 @@ export const buildChartDrilldownQueryParams = ({
|
||||
dateGranularity,
|
||||
subFieldName: groupBySubFieldName,
|
||||
timezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
primaryFilters.forEach((filter) => {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { TZDate } from '@date-fns/tz';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
import {
|
||||
getEndUnitOfDateTime,
|
||||
getPlainDateFromDate,
|
||||
getStartUnitOfDateTime,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
type ChartFilter = {
|
||||
@@ -14,39 +10,34 @@ type ChartFilter = {
|
||||
};
|
||||
|
||||
export const buildDateFilterForDayGranularity = (
|
||||
parsedBucketDate: Date,
|
||||
parsedDateTime: Temporal.ZonedDateTime,
|
||||
fieldType: FieldMetadataType,
|
||||
fieldName: string,
|
||||
timezone?: string,
|
||||
): ChartFilter[] => {
|
||||
if (fieldType === FieldMetadataType.DATE) {
|
||||
return [
|
||||
{
|
||||
fieldName,
|
||||
operand: ViewFilterOperand.IS,
|
||||
value: getPlainDateFromDate(parsedBucketDate),
|
||||
value: parsedDateTime.toPlainDate().toString(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (fieldType === FieldMetadataType.DATE_TIME) {
|
||||
const dateInTimezone = timezone
|
||||
? new TZDate(parsedBucketDate, timezone)
|
||||
: parsedBucketDate;
|
||||
|
||||
const startOfDayDate = getStartUnitOfDateTime(dateInTimezone, 'DAY');
|
||||
const endOfDayDate = getEndUnitOfDateTime(dateInTimezone, 'DAY');
|
||||
const startOfDay = parsedDateTime.startOfDay();
|
||||
const startOfNextDay = startOfDay.add({ days: 1 });
|
||||
|
||||
return [
|
||||
{
|
||||
fieldName,
|
||||
operand: ViewFilterOperand.IS_AFTER,
|
||||
value: startOfDayDate.toISOString(),
|
||||
value: startOfDay.toString({ timeZoneName: 'never' }),
|
||||
},
|
||||
{
|
||||
fieldName,
|
||||
operand: ViewFilterOperand.IS_BEFORE,
|
||||
value: endOfDayDate.toISOString(),
|
||||
value: startOfNextDay.toString({ timeZoneName: 'never' }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { buildDateFilterForDayGranularity } from '@/page-layout/widgets/graph/utils/buildDateFilterForDayGranularity';
|
||||
import { buildFiltersFromDateRange } from '@/page-layout/widgets/graph/utils/buildFiltersFromDateRange';
|
||||
import { calculateQuarterDateRange } from '@/page-layout/widgets/graph/utils/calculateQuarterDateRange';
|
||||
import { TZDate } from '@date-fns/tz';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import {
|
||||
type FirstDayOfTheWeek,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
type ViewFilterOperand,
|
||||
} from 'twenty-shared/types';
|
||||
import {
|
||||
getEndUnitOfDateTime,
|
||||
getStartUnitOfDateTime,
|
||||
} from 'twenty-shared/utils';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import { getNextPeriodStart, getPeriodStart } from 'twenty-shared/utils';
|
||||
import { type FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
export type RangeChartFilter = {
|
||||
fieldName: string;
|
||||
@@ -18,50 +16,51 @@ export type RangeChartFilter = {
|
||||
};
|
||||
|
||||
export const buildDateRangeFiltersForGranularity = (
|
||||
parsedBucketDate: Date,
|
||||
referenceDayWithTimeZone: Temporal.ZonedDateTime,
|
||||
dateGranularity: ObjectRecordGroupByDateGranularity,
|
||||
fieldType: FieldMetadataType,
|
||||
fieldName: string,
|
||||
timezone?: string,
|
||||
firstDayOfTheWeek?: FirstDayOfTheWeek,
|
||||
): RangeChartFilter[] => {
|
||||
const dateInTimezone =
|
||||
fieldType === FieldMetadataType.DATE_TIME && timezone
|
||||
? new TZDate(parsedBucketDate, timezone)
|
||||
: parsedBucketDate;
|
||||
|
||||
if (dateGranularity === ObjectRecordGroupByDateGranularity.WEEK) {
|
||||
return buildFiltersFromDateRange(
|
||||
getStartUnitOfDateTime(dateInTimezone, 'WEEK'),
|
||||
getEndUnitOfDateTime(dateInTimezone, 'WEEK'),
|
||||
fieldType,
|
||||
fieldName,
|
||||
);
|
||||
switch (dateGranularity) {
|
||||
case ObjectRecordGroupByDateGranularity.WEEK: {
|
||||
return buildFiltersFromDateRange(
|
||||
getPeriodStart(referenceDayWithTimeZone, 'WEEK', firstDayOfTheWeek),
|
||||
getNextPeriodStart(referenceDayWithTimeZone, 'WEEK', firstDayOfTheWeek),
|
||||
fieldType,
|
||||
fieldName,
|
||||
);
|
||||
}
|
||||
case ObjectRecordGroupByDateGranularity.MONTH: {
|
||||
return buildFiltersFromDateRange(
|
||||
getPeriodStart(referenceDayWithTimeZone, 'MONTH'),
|
||||
getNextPeriodStart(referenceDayWithTimeZone, 'MONTH'),
|
||||
fieldType,
|
||||
fieldName,
|
||||
);
|
||||
}
|
||||
case ObjectRecordGroupByDateGranularity.QUARTER: {
|
||||
return buildFiltersFromDateRange(
|
||||
getPeriodStart(referenceDayWithTimeZone, 'QUARTER'),
|
||||
getNextPeriodStart(referenceDayWithTimeZone, 'QUARTER'),
|
||||
fieldType,
|
||||
fieldName,
|
||||
);
|
||||
}
|
||||
case ObjectRecordGroupByDateGranularity.YEAR: {
|
||||
return buildFiltersFromDateRange(
|
||||
getPeriodStart(referenceDayWithTimeZone, 'YEAR'),
|
||||
getNextPeriodStart(referenceDayWithTimeZone, 'YEAR'),
|
||||
fieldType,
|
||||
fieldName,
|
||||
);
|
||||
}
|
||||
case ObjectRecordGroupByDateGranularity.DAY:
|
||||
default:
|
||||
return buildDateFilterForDayGranularity(
|
||||
referenceDayWithTimeZone,
|
||||
fieldType,
|
||||
fieldName,
|
||||
);
|
||||
}
|
||||
|
||||
if (dateGranularity === ObjectRecordGroupByDateGranularity.MONTH) {
|
||||
return buildFiltersFromDateRange(
|
||||
getStartUnitOfDateTime(dateInTimezone, 'MONTH'),
|
||||
getEndUnitOfDateTime(dateInTimezone, 'MONTH'),
|
||||
fieldType,
|
||||
fieldName,
|
||||
);
|
||||
}
|
||||
|
||||
if (dateGranularity === ObjectRecordGroupByDateGranularity.QUARTER) {
|
||||
const quarterRange = calculateQuarterDateRange(dateInTimezone, timezone);
|
||||
|
||||
return buildFiltersFromDateRange(
|
||||
quarterRange.rangeStartDate,
|
||||
quarterRange.rangeEndDate,
|
||||
fieldType,
|
||||
fieldName,
|
||||
);
|
||||
}
|
||||
|
||||
return buildFiltersFromDateRange(
|
||||
getStartUnitOfDateTime(dateInTimezone, 'YEAR'),
|
||||
getEndUnitOfDateTime(dateInTimezone, 'YEAR'),
|
||||
fieldType,
|
||||
fieldName,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,13 +2,13 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataIte
|
||||
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
|
||||
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
|
||||
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
|
||||
import { buildDateFilterForDayGranularity } from '@/page-layout/widgets/graph/utils/buildDateFilterForDayGranularity';
|
||||
import { buildDateRangeFiltersForGranularity } from '@/page-layout/widgets/graph/utils/buildDateRangeFiltersForGranularity';
|
||||
import { isCyclicalDateGranularity } from '@/page-layout/widgets/graph/utils/isCyclicalDateGranularity';
|
||||
import { isTimeRangeDateGranularity } from '@/page-layout/widgets/graph/utils/isTimeRangeDateGranularity';
|
||||
import { serializeChartBucketValueForFilter } from '@/page-layout/widgets/graph/utils/serializeChartBucketValueForFilter';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import {
|
||||
type FirstDayOfTheWeek,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
ViewFilterOperand,
|
||||
} from 'twenty-shared/types';
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getFilterTypeFromFieldType,
|
||||
isDefined,
|
||||
isFieldMetadataDateKind,
|
||||
parseToPlainDateOrThrow,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
type ChartFilter = {
|
||||
@@ -30,6 +31,7 @@ type BuildFilterFromChartBucketParams = {
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity | null;
|
||||
subFieldName?: string | null;
|
||||
timezone?: string;
|
||||
firstDayOfTheWeek?: FirstDayOfTheWeek;
|
||||
};
|
||||
|
||||
export const buildFilterFromChartBucket = ({
|
||||
@@ -38,6 +40,7 @@ export const buildFilterFromChartBucket = ({
|
||||
dateGranularity,
|
||||
subFieldName,
|
||||
timezone,
|
||||
firstDayOfTheWeek,
|
||||
}: BuildFilterFromChartBucketParams): ChartFilter[] => {
|
||||
const fieldName = isNonEmptyString(subFieldName)
|
||||
? `${fieldMetadataItem.name}.${subFieldName}`
|
||||
@@ -61,36 +64,64 @@ export const buildFilterFromChartBucket = ({
|
||||
}
|
||||
|
||||
if (isFieldMetadataDateKind(fieldMetadataItem.type)) {
|
||||
const parsedBucketDate = new Date(String(bucketRawValue));
|
||||
|
||||
if (isNaN(parsedBucketDate.getTime())) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isCyclicalDateGranularity(dateGranularity)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (
|
||||
const shouldAssumeDayRangeFilter =
|
||||
!isDefined(dateGranularity) ||
|
||||
dateGranularity === ObjectRecordGroupByDateGranularity.DAY ||
|
||||
dateGranularity === ObjectRecordGroupByDateGranularity.NONE
|
||||
) {
|
||||
return buildDateFilterForDayGranularity(
|
||||
parsedBucketDate,
|
||||
dateGranularity === ObjectRecordGroupByDateGranularity.NONE;
|
||||
|
||||
if (shouldAssumeDayRangeFilter) {
|
||||
if (!isNonEmptyString(timezone)) {
|
||||
throw new Error(
|
||||
`Timezone should be defined for date granularity group by day`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDefined(firstDayOfTheWeek)) {
|
||||
throw new Error(
|
||||
`First day of the week should be defined for date granularity group by day`,
|
||||
);
|
||||
}
|
||||
|
||||
const parsedZonedDateTime = parseToPlainDateOrThrow(
|
||||
String(bucketRawValue),
|
||||
).toZonedDateTime(timezone);
|
||||
|
||||
return buildDateRangeFiltersForGranularity(
|
||||
parsedZonedDateTime,
|
||||
ObjectRecordGroupByDateGranularity.DAY,
|
||||
fieldMetadataItem.type,
|
||||
fieldName,
|
||||
timezone,
|
||||
firstDayOfTheWeek,
|
||||
);
|
||||
}
|
||||
|
||||
if (isTimeRangeDateGranularity(dateGranularity)) {
|
||||
if (!isNonEmptyString(timezone)) {
|
||||
throw new Error(
|
||||
`Timezone should be defined for date granularity group by`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDefined(firstDayOfTheWeek)) {
|
||||
throw new Error(
|
||||
`First day of the week should be defined for date granularity group by`,
|
||||
);
|
||||
}
|
||||
|
||||
const parsedDateTime = parseToPlainDateOrThrow(
|
||||
String(bucketRawValue),
|
||||
).toZonedDateTime(timezone);
|
||||
|
||||
return buildDateRangeFiltersForGranularity(
|
||||
parsedBucketDate,
|
||||
parsedDateTime,
|
||||
dateGranularity,
|
||||
fieldMetadataItem.type,
|
||||
fieldName,
|
||||
timezone,
|
||||
firstDayOfTheWeek,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { type RangeChartFilter } from '@/page-layout/widgets/graph/utils/buildDateRangeFiltersForGranularity';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
import { getPlainDateFromDate } from 'twenty-shared/utils';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
export const buildFiltersFromDateRange = (
|
||||
rangeStartDate: Date,
|
||||
rangeEndDate: Date,
|
||||
rangeStartDate: Temporal.ZonedDateTime,
|
||||
rangeEndDate: Temporal.ZonedDateTime,
|
||||
fieldType: FieldMetadataType,
|
||||
fieldName: string,
|
||||
): RangeChartFilter[] => {
|
||||
@@ -14,12 +14,12 @@ export const buildFiltersFromDateRange = (
|
||||
{
|
||||
fieldName,
|
||||
operand: ViewFilterOperand.IS_AFTER,
|
||||
value: rangeStartDate.toISOString(),
|
||||
value: rangeStartDate.toString({ timeZoneName: 'never' }),
|
||||
},
|
||||
{
|
||||
fieldName,
|
||||
operand: ViewFilterOperand.IS_BEFORE,
|
||||
value: rangeEndDate.toISOString(),
|
||||
value: rangeEndDate.toString({ timeZoneName: 'never' }),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -28,12 +28,12 @@ export const buildFiltersFromDateRange = (
|
||||
{
|
||||
fieldName,
|
||||
operand: ViewFilterOperand.IS_AFTER,
|
||||
value: getPlainDateFromDate(rangeStartDate),
|
||||
value: rangeStartDate.toPlainDate().toString(),
|
||||
},
|
||||
{
|
||||
fieldName,
|
||||
operand: ViewFilterOperand.IS_BEFORE,
|
||||
value: getPlainDateFromDate(rangeEndDate),
|
||||
value: rangeEndDate.toPlainDate().toString(),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -3,7 +3,10 @@ import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/uti
|
||||
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
|
||||
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
|
||||
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity';
|
||||
import { CalendarStartDay } from 'twenty-shared';
|
||||
import {
|
||||
CalendarStartDay,
|
||||
GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE,
|
||||
} from 'twenty-shared/constants';
|
||||
import {
|
||||
type FirstDayOfTheWeek,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
@@ -15,19 +18,23 @@ export type GroupByFieldObject = Record<
|
||||
boolean | Record<string, boolean | string | Record<string, boolean | string>>
|
||||
>;
|
||||
|
||||
export type GroupByFieldObjectParams = {
|
||||
field: FieldMetadataItem;
|
||||
subFieldName?: string | null;
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity;
|
||||
firstDayOfTheWeek?: number | null;
|
||||
isNestedDateField?: boolean;
|
||||
timeZone?: string;
|
||||
};
|
||||
|
||||
export const buildGroupByFieldObject = ({
|
||||
field,
|
||||
subFieldName,
|
||||
dateGranularity,
|
||||
firstDayOfTheWeek,
|
||||
isNestedDateField,
|
||||
}: {
|
||||
field: FieldMetadataItem;
|
||||
subFieldName?: string | null;
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity;
|
||||
firstDayOfTheWeek?: number | null;
|
||||
isNestedDateField?: boolean;
|
||||
}): GroupByFieldObject => {
|
||||
timeZone,
|
||||
}: GroupByFieldObjectParams): GroupByFieldObject => {
|
||||
const isRelation = isFieldRelation(field) || isFieldMorphRelation(field);
|
||||
const isComposite = isCompositeFieldType(field.type);
|
||||
const isDateField = isFieldMetadataDateKind(field.type);
|
||||
@@ -41,11 +48,36 @@ export const buildGroupByFieldObject = ({
|
||||
const nestedFieldName = parts[0];
|
||||
const nestedSubFieldName = parts[1];
|
||||
|
||||
if (isNestedDateField === true || isDefined(dateGranularity)) {
|
||||
if (isNestedDateField === true) {
|
||||
const usedDateGranularity =
|
||||
dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY;
|
||||
|
||||
const shouldHaveTimeZone =
|
||||
GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE.includes(
|
||||
usedDateGranularity,
|
||||
);
|
||||
|
||||
const timeZoneIsNotProvied = !isDefined(timeZone);
|
||||
|
||||
if (shouldHaveTimeZone) {
|
||||
if (timeZoneIsNotProvied) {
|
||||
throw new Error(`Date order by should have a time zone.`);
|
||||
} else {
|
||||
return {
|
||||
[field.name]: {
|
||||
[nestedFieldName]: {
|
||||
granularity: usedDateGranularity,
|
||||
timeZone,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
[field.name]: {
|
||||
[nestedFieldName]: {
|
||||
granularity: dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY,
|
||||
granularity: usedDateGranularity,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -82,11 +114,28 @@ export const buildGroupByFieldObject = ({
|
||||
}
|
||||
|
||||
if (isDateField) {
|
||||
const granularity = dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY;
|
||||
const result: Record<string, string> = { granularity };
|
||||
const usedDateGranularity =
|
||||
dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY;
|
||||
|
||||
const shouldHaveTimeZone =
|
||||
GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE.includes(
|
||||
usedDateGranularity,
|
||||
);
|
||||
|
||||
const timeZoneIsNotProvied = !isDefined(timeZone);
|
||||
|
||||
const result: Record<string, string> = { granularity: usedDateGranularity };
|
||||
|
||||
if (shouldHaveTimeZone) {
|
||||
if (timeZoneIsNotProvied) {
|
||||
throw new Error(`Date order by should have a time zone.`);
|
||||
} else {
|
||||
result.timeZone = timeZone;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
granularity === ObjectRecordGroupByDateGranularity.WEEK &&
|
||||
usedDateGranularity === ObjectRecordGroupByDateGranularity.WEEK &&
|
||||
isDefined(firstDayOfTheWeek) &&
|
||||
firstDayOfTheWeek !== CalendarStartDay.SYSTEM
|
||||
) {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { TZDate } from '@date-fns/tz';
|
||||
import { getEndUnitOfDateTime } from 'twenty-shared/utils';
|
||||
|
||||
type QuarterDateRange = {
|
||||
rangeStartDate: Date;
|
||||
rangeEndDate: Date;
|
||||
};
|
||||
|
||||
export const calculateQuarterDateRange = (
|
||||
parsedBucketDate: Date,
|
||||
timezone?: string,
|
||||
): QuarterDateRange => {
|
||||
const quarterStartMonthIndex =
|
||||
Math.floor(parsedBucketDate.getMonth() / 3) * 3;
|
||||
const rangeStartDate = timezone
|
||||
? new TZDate(
|
||||
parsedBucketDate.getFullYear(),
|
||||
quarterStartMonthIndex,
|
||||
1,
|
||||
timezone,
|
||||
)
|
||||
: new Date(parsedBucketDate.getFullYear(), quarterStartMonthIndex, 1);
|
||||
|
||||
const quarterFinalMonthIndex = quarterStartMonthIndex + 2;
|
||||
const rangeEndDate = getEndUnitOfDateTime(
|
||||
timezone
|
||||
? new TZDate(
|
||||
parsedBucketDate.getFullYear(),
|
||||
quarterFinalMonthIndex,
|
||||
1,
|
||||
timezone,
|
||||
)
|
||||
: new Date(parsedBucketDate.getFullYear(), quarterFinalMonthIndex, 1),
|
||||
'MONTH',
|
||||
);
|
||||
|
||||
return { rangeStartDate, rangeEndDate };
|
||||
};
|
||||
@@ -1,7 +1,12 @@
|
||||
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import {
|
||||
type FirstDayOfTheWeek,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
import { getNextPeriodStart, getPeriodStart } from 'twenty-shared/utils';
|
||||
|
||||
export const formatDateByGranularity = (
|
||||
date: Date,
|
||||
plainDate: Temporal.PlainDate,
|
||||
granularity:
|
||||
| ObjectRecordGroupByDateGranularity.DAY
|
||||
| ObjectRecordGroupByDateGranularity.MONTH
|
||||
@@ -9,29 +14,39 @@ export const formatDateByGranularity = (
|
||||
| ObjectRecordGroupByDateGranularity.YEAR
|
||||
| ObjectRecordGroupByDateGranularity.WEEK
|
||||
| ObjectRecordGroupByDateGranularity.NONE,
|
||||
userTimezone: string,
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek,
|
||||
): string => {
|
||||
switch (granularity) {
|
||||
case ObjectRecordGroupByDateGranularity.DAY:
|
||||
return date.toLocaleDateString(undefined, {
|
||||
return plainDate.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
case ObjectRecordGroupByDateGranularity.WEEK: {
|
||||
const weekStart = new Date(date);
|
||||
const weekEnd = new Date(date);
|
||||
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||
const startOfWeek = getPeriodStart(
|
||||
plainDate.toZonedDateTime(userTimezone),
|
||||
'WEEK',
|
||||
firstDayOfTheWeek,
|
||||
);
|
||||
|
||||
const startMonth = weekStart.toLocaleDateString(undefined, {
|
||||
const endOfWeek = getNextPeriodStart(
|
||||
plainDate.toZonedDateTime(userTimezone),
|
||||
'WEEK',
|
||||
firstDayOfTheWeek,
|
||||
).subtract({ days: 1 });
|
||||
|
||||
const startMonth = startOfWeek.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
});
|
||||
const endMonth = weekEnd.toLocaleDateString(undefined, {
|
||||
const endMonth = endOfWeek.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
});
|
||||
const startDay = weekStart.getDate();
|
||||
const endDay = weekEnd.getDate();
|
||||
const startYear = weekStart.getFullYear();
|
||||
const endYear = weekEnd.getFullYear();
|
||||
const startDay = startOfWeek.day;
|
||||
const endDay = endOfWeek.day;
|
||||
const startYear = startOfWeek.year;
|
||||
const endYear = endOfWeek.year;
|
||||
|
||||
if (startYear !== endYear) {
|
||||
return `${startMonth} ${startDay}, ${startYear} - ${endMonth} ${endDay}, ${endYear}`;
|
||||
@@ -44,16 +59,17 @@ export const formatDateByGranularity = (
|
||||
return `${startMonth} ${startDay} - ${endDay}, ${endYear}`;
|
||||
}
|
||||
case ObjectRecordGroupByDateGranularity.MONTH:
|
||||
return date.toLocaleDateString(undefined, {
|
||||
return plainDate.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
case ObjectRecordGroupByDateGranularity.QUARTER:
|
||||
return `Q${Math.floor(date.getMonth() / 3) + 1} ${date.getFullYear()}`;
|
||||
case ObjectRecordGroupByDateGranularity.QUARTER: {
|
||||
return `Q${Math.ceil(plainDate.month / 3)} ${plainDate.year}`;
|
||||
}
|
||||
case ObjectRecordGroupByDateGranularity.YEAR:
|
||||
return date.getFullYear().toString();
|
||||
return plainDate.year.toString();
|
||||
case ObjectRecordGroupByDateGranularity.NONE:
|
||||
default:
|
||||
return date.toLocaleDateString();
|
||||
return plainDate.toLocaleString();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,9 +5,10 @@ import { t } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
type FirstDayOfTheWeek,
|
||||
ObjectRecordGroupByDateGranularity,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined, parseToPlainDateOrThrow } from 'twenty-shared/utils';
|
||||
import { formatToShortNumber } from '~/utils/format/formatToShortNumber';
|
||||
|
||||
type FormatDimensionValueParams = {
|
||||
@@ -15,6 +16,8 @@ type FormatDimensionValueParams = {
|
||||
fieldMetadata: FieldMetadataItem;
|
||||
dateGranularity?: ObjectRecordGroupByDateGranularity;
|
||||
subFieldName?: string;
|
||||
userTimezone: string;
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek;
|
||||
};
|
||||
|
||||
const normalizeMultiSelectValue = (value: unknown): unknown[] => {
|
||||
@@ -43,6 +46,8 @@ export const formatDimensionValue = ({
|
||||
fieldMetadata,
|
||||
dateGranularity,
|
||||
subFieldName,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
}: FormatDimensionValueParams): string => {
|
||||
if (!isDefined(value)) {
|
||||
return t`Not Set`;
|
||||
@@ -78,12 +83,6 @@ export const formatDimensionValue = ({
|
||||
|
||||
case FieldMetadataType.DATE:
|
||||
case FieldMetadataType.DATE_TIME: {
|
||||
const parsedDate = new Date(String(value));
|
||||
|
||||
if (isNaN(parsedDate.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (
|
||||
effectiveDateGranularity ===
|
||||
ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK ||
|
||||
@@ -94,15 +93,21 @@ export const formatDimensionValue = ({
|
||||
) {
|
||||
return String(value);
|
||||
}
|
||||
return formatDateByGranularity(parsedDate, effectiveDateGranularity);
|
||||
|
||||
const parsedPlainDate = parseToPlainDateOrThrow(String(value));
|
||||
|
||||
return formatDateByGranularity(
|
||||
parsedPlainDate,
|
||||
effectiveDateGranularity,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
);
|
||||
}
|
||||
|
||||
case FieldMetadataType.RELATION: {
|
||||
if (isDefined(dateGranularity)) {
|
||||
const parsedDate = new Date(String(value));
|
||||
if (isNaN(parsedDate.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
const parsedDayString = String(value);
|
||||
|
||||
if (
|
||||
dateGranularity ===
|
||||
ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK ||
|
||||
@@ -113,7 +118,7 @@ export const formatDimensionValue = ({
|
||||
) {
|
||||
return String(value);
|
||||
}
|
||||
return formatDateByGranularity(parsedDate, dateGranularity);
|
||||
return parsedDayString;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataIte
|
||||
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
|
||||
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
|
||||
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
|
||||
import { type FirstDayOfTheWeek } from 'twenty-shared/types';
|
||||
import { type ObjectRecordGroupByDateGranularity } from '~/generated/graphql';
|
||||
|
||||
type FormatPrimaryDimensionValuesParameters = {
|
||||
@@ -9,6 +10,8 @@ type FormatPrimaryDimensionValuesParameters = {
|
||||
primaryAxisGroupByField: FieldMetadataItem;
|
||||
primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity;
|
||||
primaryAxisGroupBySubFieldName?: string;
|
||||
userTimezone: string;
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek;
|
||||
};
|
||||
|
||||
export type FormattedDimensionValue = {
|
||||
@@ -21,6 +24,8 @@ export const formatPrimaryDimensionValues = ({
|
||||
primaryAxisGroupByField,
|
||||
primaryAxisDateGranularity,
|
||||
primaryAxisGroupBySubFieldName,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
}: FormatPrimaryDimensionValuesParameters): FormattedDimensionValue[] => {
|
||||
return groupByRawResults.reduce<FormattedDimensionValue[]>(
|
||||
(accumulator, rawResult) => {
|
||||
@@ -34,6 +39,8 @@ export const formatPrimaryDimensionValues = ({
|
||||
fieldMetadata: primaryAxisGroupByField,
|
||||
dateGranularity: primaryAxisDateGranularity,
|
||||
subFieldName: primaryAxisGroupBySubFieldName,
|
||||
userTimezone,
|
||||
firstDayOfTheWeek,
|
||||
});
|
||||
|
||||
return [
|
||||
|
||||
@@ -27,6 +27,7 @@ export const generateGroupByQueryVariablesFromBarOrLineChartConfiguration = ({
|
||||
aggregateOperation,
|
||||
limit,
|
||||
firstDayOfTheWeek,
|
||||
userTimeZone,
|
||||
}: {
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
objectMetadataItems: ObjectMetadataItem[];
|
||||
@@ -34,6 +35,7 @@ export const generateGroupByQueryVariablesFromBarOrLineChartConfiguration = ({
|
||||
aggregateOperation?: string;
|
||||
limit?: number;
|
||||
firstDayOfTheWeek?: number;
|
||||
userTimeZone?: string;
|
||||
}) => {
|
||||
const groupByFieldXId = chartConfiguration.primaryAxisGroupByFieldMetadataId;
|
||||
|
||||
@@ -82,6 +84,7 @@ export const generateGroupByQueryVariablesFromBarOrLineChartConfiguration = ({
|
||||
: undefined,
|
||||
firstDayOfTheWeek,
|
||||
isNestedDateField: isFieldXNestedDate,
|
||||
timeZone: userTimeZone,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -106,6 +109,7 @@ export const generateGroupByQueryVariablesFromBarOrLineChartConfiguration = ({
|
||||
: undefined,
|
||||
firstDayOfTheWeek,
|
||||
isNestedDateField: isFieldYNestedDate,
|
||||
timeZone: userTimeZone,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export const generateGroupByQueryVariablesFromPieChartConfiguration = ({
|
||||
aggregateOperation,
|
||||
limit,
|
||||
firstDayOfTheWeek,
|
||||
userTimeZone,
|
||||
}: {
|
||||
objectMetadataItem: ObjectMetadataItem;
|
||||
objectMetadataItems: ObjectMetadataItem[];
|
||||
@@ -30,6 +31,7 @@ export const generateGroupByQueryVariablesFromPieChartConfiguration = ({
|
||||
aggregateOperation?: string;
|
||||
limit?: number;
|
||||
firstDayOfTheWeek?: number;
|
||||
userTimeZone?: string;
|
||||
}) => {
|
||||
const groupByFieldId = chartConfiguration.groupByFieldMetadataId;
|
||||
const groupBySubFieldName =
|
||||
@@ -65,6 +67,7 @@ export const generateGroupByQueryVariablesFromPieChartConfiguration = ({
|
||||
: undefined,
|
||||
firstDayOfTheWeek,
|
||||
isNestedDateField: isNestedDate,
|
||||
timeZone: userTimeZone,
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user