@servicetitan/marketing-ui 0.5.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/image-cropper/image-cropper.d.ts +23 -0
- package/dist/components/image-cropper/image-cropper.d.ts.map +1 -0
- package/dist/components/image-cropper/image-cropper.js +146 -0
- package/dist/components/image-cropper/image-cropper.js.map +1 -0
- package/dist/components/image-cropper/image-cropper.stories.d.ts +10 -0
- package/dist/components/image-cropper/image-cropper.stories.d.ts.map +1 -0
- package/dist/components/image-cropper/image-cropper.stories.js +55 -0
- package/dist/components/image-cropper/image-cropper.stories.js.map +1 -0
- package/dist/components/ui/date-range-picker/date-range-picker.d.ts +10 -0
- package/dist/components/ui/date-range-picker/date-range-picker.d.ts.map +1 -0
- package/dist/components/ui/date-range-picker/date-range-picker.js +77 -0
- package/dist/components/ui/date-range-picker/date-range-picker.js.map +1 -0
- package/dist/components/ui/date-range-picker/date-range-picker.module.less +42 -0
- package/dist/components/ui/date-range-picker/date-range-picker.stories.d.ts +10 -0
- package/dist/components/ui/date-range-picker/date-range-picker.stories.d.ts.map +1 -0
- package/dist/components/ui/date-range-picker/date-range-picker.stories.js +17 -0
- package/dist/components/ui/date-range-picker/date-range-picker.stories.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/utils/date/__mocks__/date-mock.d.ts +5 -0
- package/dist/utils/date/__mocks__/date-mock.d.ts.map +1 -0
- package/dist/utils/date/__mocks__/date-mock.js +23 -0
- package/dist/utils/date/__mocks__/date-mock.js.map +1 -0
- package/dist/utils/date/date-range-picker-options.d.ts +45 -0
- package/dist/utils/date/date-range-picker-options.d.ts.map +1 -0
- package/dist/utils/date/date-range-picker-options.js +138 -0
- package/dist/utils/date/date-range-picker-options.js.map +1 -0
- package/dist/utils/date/date-range-picker-state.d.ts +24 -0
- package/dist/utils/date/date-range-picker-state.d.ts.map +1 -0
- package/dist/utils/date/date-range-picker-state.js +73 -0
- package/dist/utils/date/date-range-picker-state.js.map +1 -0
- package/dist/utils/date/date-tenant.d.ts +17 -0
- package/dist/utils/date/date-tenant.d.ts.map +1 -0
- package/dist/utils/date/date-tenant.js +51 -0
- package/dist/utils/date/date-tenant.js.map +1 -0
- package/dist/utils/date/date.d.ts +11 -0
- package/dist/utils/date/date.d.ts.map +1 -0
- package/dist/utils/date/date.js +22 -0
- package/dist/utils/date/date.js.map +1 -0
- package/dist/utils/date/index.d.ts +5 -0
- package/dist/utils/date/index.d.ts.map +1 -0
- package/dist/utils/date/index.js +17 -0
- package/dist/utils/date/index.js.map +1 -0
- package/dist/utils/history/history.d.ts +9 -3
- package/dist/utils/history/history.d.ts.map +1 -1
- package/dist/utils/history/history.js +9 -7
- package/dist/utils/history/history.js.map +1 -1
- package/dist/utils/history/index.d.ts +1 -0
- package/dist/utils/history/index.d.ts.map +1 -1
- package/dist/utils/history/index.js +1 -0
- package/dist/utils/history/index.js.map +1 -1
- package/dist/utils/history/query-params-handler.d.ts +1 -0
- package/dist/utils/history/query-params-handler.d.ts.map +1 -1
- package/dist/utils/history/query-params-handler.js +3 -0
- package/dist/utils/history/query-params-handler.js.map +1 -1
- package/dist/utils/history/url-params-handler.d.ts +18 -0
- package/dist/utils/history/url-params-handler.d.ts.map +1 -0
- package/dist/utils/history/url-params-handler.js +64 -0
- package/dist/utils/history/url-params-handler.js.map +1 -0
- package/dist/utils/history/use-query-params.d.ts.map +1 -1
- package/dist/utils/history/use-query-params.js +5 -3
- package/dist/utils/history/use-query-params.js.map +1 -1
- package/dist/utils/history/use-url-params.d.ts +5 -5
- package/dist/utils/history/use-url-params.d.ts.map +1 -1
- package/dist/utils/history/use-url-params.js +4 -19
- package/dist/utils/history/use-url-params.js.map +1 -1
- package/dist/utils/{use-init-effect.d.ts → invariable-hooks.d.ts} +6 -1
- package/dist/utils/invariable-hooks.d.ts.map +1 -0
- package/dist/utils/{use-init-effect.js → invariable-hooks.js} +11 -2
- package/dist/utils/invariable-hooks.js.map +1 -0
- package/dist/utils/param-parsers.d.ts +2 -1
- package/dist/utils/param-parsers.d.ts.map +1 -1
- package/dist/utils/param-parsers.js +21 -1
- package/dist/utils/param-parsers.js.map +1 -1
- package/package.json +5 -3
- package/src/components/image-cropper/image-cropper.stories.tsx +69 -0
- package/src/components/image-cropper/image-cropper.tsx +108 -0
- package/src/components/ui/date-range-picker/date-range-picker.module.less +42 -0
- package/src/components/ui/date-range-picker/date-range-picker.module.less.d.ts +4 -0
- package/src/components/ui/date-range-picker/date-range-picker.stories.tsx +22 -0
- package/src/components/ui/date-range-picker/date-range-picker.tsx +118 -0
- package/src/index.ts +2 -1
- package/src/utils/__tests__/param-parsers.test.ts +11 -2
- package/src/utils/date/__mocks__/date-mock.ts +23 -0
- package/src/utils/date/__tests__/date-range-picker.test.ts +139 -0
- package/src/utils/date/__tests__/date-tenant.test.ts +38 -0
- package/src/utils/date/date-range-picker-options.ts +167 -0
- package/src/utils/date/date-range-picker-state.ts +62 -0
- package/src/utils/date/date-tenant.ts +49 -0
- package/src/utils/date/date.ts +29 -0
- package/src/utils/date/index.ts +4 -0
- package/src/utils/history/__tests__/history.test.ts +9 -2
- package/src/utils/history/__tests__/url-params-handler.test.ts +32 -0
- package/src/utils/history/__tests__/use-url-params.test.ts +2 -1
- package/src/utils/history/history.ts +27 -10
- package/src/utils/history/index.ts +1 -0
- package/src/utils/history/query-params-handler.ts +4 -0
- package/src/utils/history/url-params-handler.ts +65 -0
- package/src/utils/history/use-query-params.ts +5 -3
- package/src/utils/history/use-url-params.ts +7 -32
- package/src/utils/{use-init-effect.ts → invariable-hooks.ts} +10 -1
- package/src/utils/param-parsers.ts +26 -1
- package/dist/utils/use-init-effect.d.ts.map +0 -1
- package/dist/utils/use-init-effect.js.map +0 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import classNames from 'classnames';
|
|
3
|
+
import { observer } from 'mobx-react';
|
|
4
|
+
import { BodyText, Form, Stack } from '@servicetitan/design-system';
|
|
5
|
+
import { DateRange, DateRangePickerOptionsStateType } from '../../../utils/date';
|
|
6
|
+
import * as Styles from './date-range-picker.module.less';
|
|
7
|
+
|
|
8
|
+
export interface DateRangePickerProps<OptionKeys extends string> {
|
|
9
|
+
state: DateRangePickerOptionsStateType<OptionKeys>;
|
|
10
|
+
onChange?: DateRangePickerOptionsStateType<OptionKeys>['onChange'];
|
|
11
|
+
compare?: boolean;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const convertDateRange = (val?: DateRange, backward?: boolean): DateRange | undefined =>
|
|
16
|
+
val
|
|
17
|
+
? {
|
|
18
|
+
start: val.start,
|
|
19
|
+
end: new Date(
|
|
20
|
+
val.end.getFullYear(),
|
|
21
|
+
val.end.getMonth(),
|
|
22
|
+
val.end.getDate() + (backward ? 1 : -1)
|
|
23
|
+
),
|
|
24
|
+
}
|
|
25
|
+
: undefined;
|
|
26
|
+
|
|
27
|
+
const formatDate = (dt: Date) =>
|
|
28
|
+
dt.toLocaleString('en-US', {
|
|
29
|
+
day: 'numeric',
|
|
30
|
+
year: 'numeric',
|
|
31
|
+
month: 'short',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const formatDateRange = (range: DateRange) =>
|
|
35
|
+
`${formatDate(range.start)} - ${formatDate(range.end)}`;
|
|
36
|
+
|
|
37
|
+
export const DateRangePicker = observer(
|
|
38
|
+
<OptionKeys extends string>({
|
|
39
|
+
compare,
|
|
40
|
+
disabled,
|
|
41
|
+
state,
|
|
42
|
+
onChange,
|
|
43
|
+
}: DateRangePickerProps<OptionKeys>) => {
|
|
44
|
+
const selectedOption = state.selectedOption;
|
|
45
|
+
|
|
46
|
+
/*
|
|
47
|
+
* DateRangePickerOptions uses exclusive date ranges for end date (start >= dt > end)
|
|
48
|
+
* but anvil's DateRangePicker uses inclusive dates (with end set to endOfDay),
|
|
49
|
+
* so we performing some transformations here
|
|
50
|
+
* convertDateRange (predefined options, value and onChange)
|
|
51
|
+
*/
|
|
52
|
+
const onPickerChange: DateRangePickerOptionsStateType<OptionKeys>['onChange'] = val => {
|
|
53
|
+
const newVal = convertDateRange(val, true);
|
|
54
|
+
|
|
55
|
+
if (onChange) {
|
|
56
|
+
onChange(newVal);
|
|
57
|
+
} else {
|
|
58
|
+
state.onChange(newVal);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const options = useMemo(
|
|
62
|
+
() =>
|
|
63
|
+
state.options.options.map(o => ({
|
|
64
|
+
text: o.text,
|
|
65
|
+
value: convertDateRange(o.value),
|
|
66
|
+
selected: o.key === selectedOption?.key,
|
|
67
|
+
})),
|
|
68
|
+
[selectedOption, state.options.options]
|
|
69
|
+
);
|
|
70
|
+
const value = useMemo(() => convertDateRange(state.value), [state.value]);
|
|
71
|
+
const compareValue = useMemo(
|
|
72
|
+
() =>
|
|
73
|
+
state.value && compare
|
|
74
|
+
? convertDateRange(state.options.getComparisonDateRange(state.value))
|
|
75
|
+
: undefined,
|
|
76
|
+
[compare, state.value, state.options]
|
|
77
|
+
);
|
|
78
|
+
const textValue = selectedOption
|
|
79
|
+
? selectedOption.text
|
|
80
|
+
: compare || !value
|
|
81
|
+
? 'Custom Date Range'
|
|
82
|
+
: formatDateRange(value);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<Stack>
|
|
86
|
+
<Stack.Item className={Styles.pickerContainer}>
|
|
87
|
+
<Form.DateRangePicker
|
|
88
|
+
onChange={onPickerChange}
|
|
89
|
+
options={options}
|
|
90
|
+
value={value}
|
|
91
|
+
required
|
|
92
|
+
disabled={disabled}
|
|
93
|
+
label={
|
|
94
|
+
<div
|
|
95
|
+
className={classNames(
|
|
96
|
+
Styles.pickerValue,
|
|
97
|
+
'd-f flex-column justify-content-center t-truncate cursor-pointer'
|
|
98
|
+
)}
|
|
99
|
+
>
|
|
100
|
+
{textValue}
|
|
101
|
+
</div>
|
|
102
|
+
}
|
|
103
|
+
/>
|
|
104
|
+
</Stack.Item>
|
|
105
|
+
{!!compare && (
|
|
106
|
+
<Stack direction="column" justifyContent="center" className="m-l-2">
|
|
107
|
+
<BodyText>{value ? formatDateRange(value) : ''}</BodyText>
|
|
108
|
+
{!!compareValue && (
|
|
109
|
+
<BodyText size="xsmall" subdued>
|
|
110
|
+
vs. {formatDateRange(compareValue)}
|
|
111
|
+
</BodyText>
|
|
112
|
+
)}
|
|
113
|
+
</Stack>
|
|
114
|
+
)}
|
|
115
|
+
</Stack>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
);
|
package/src/index.ts
CHANGED
|
@@ -2,12 +2,13 @@ export * from './components/stat/stat-card';
|
|
|
2
2
|
export * from './components/ads/ads-stat';
|
|
3
3
|
export * from './components/ui/centered-spinner';
|
|
4
4
|
export * from './components/ui/line-text';
|
|
5
|
+
export * from './components/image-cropper/image-cropper';
|
|
5
6
|
|
|
6
7
|
export * from './utils/ads-texts';
|
|
7
8
|
export * from './utils/formatters';
|
|
8
9
|
export * from './utils/string-case';
|
|
9
10
|
export * from './utils/use-client-rect';
|
|
10
|
-
export * from './utils/
|
|
11
|
+
export * from './utils/invariable-hooks';
|
|
11
12
|
export * from './utils/history';
|
|
12
13
|
export * from './utils/helpers';
|
|
13
14
|
export * from './utils/param-parsers';
|
|
@@ -6,14 +6,23 @@ describe('paramParsers', () => {
|
|
|
6
6
|
expect(paramParsers.string.stringify('qq')).toEqual('qq');
|
|
7
7
|
});
|
|
8
8
|
|
|
9
|
-
it('should parse strings
|
|
10
|
-
const parser = paramParsers.
|
|
9
|
+
it('should parse strings set', () => {
|
|
10
|
+
const parser = paramParsers.stringSet(['Search', 'Display']);
|
|
11
11
|
|
|
12
12
|
expect(parser.parse('qq')).toEqual(undefined);
|
|
13
13
|
expect(parser.parse('Search')).toEqual('Search');
|
|
14
14
|
expect(parser.stringify('Search')).toEqual('Search');
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
+
it('should parse strings enums', () => {
|
|
18
|
+
const parser = paramParsers.stringEnum({ Search: 1, Display: 0 });
|
|
19
|
+
|
|
20
|
+
expect(parser.parse('qq')).toEqual(undefined);
|
|
21
|
+
expect(parser.parse('Search')).toEqual(1);
|
|
22
|
+
expect(parser.parse('Display')).toEqual(0);
|
|
23
|
+
expect(parser.stringify(0)).toEqual('Display');
|
|
24
|
+
});
|
|
25
|
+
|
|
17
26
|
it('should parse uri', () => {
|
|
18
27
|
expect(paramParsers.uri.parse('qq%26qq')).toEqual('qq&qq');
|
|
19
28
|
expect(paramParsers.uri.stringify('qq&qq')).toEqual('qq&qq');
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const dateMockSpy = () => {
|
|
2
|
+
let dateSpy: jest.SpyInstance;
|
|
3
|
+
|
|
4
|
+
return {
|
|
5
|
+
mock: (dateStr: string, localOffset = -240) => {
|
|
6
|
+
const OriginalDate = Date;
|
|
7
|
+
const dt = new OriginalDate(dateStr);
|
|
8
|
+
|
|
9
|
+
dt.getTimezoneOffset = () => localOffset;
|
|
10
|
+
|
|
11
|
+
dateSpy = jest
|
|
12
|
+
.spyOn(global, 'Date')
|
|
13
|
+
.mockImplementation((...args) =>
|
|
14
|
+
args.length ? new OriginalDate(...args) : (dt as any)
|
|
15
|
+
);
|
|
16
|
+
},
|
|
17
|
+
clear: () => {
|
|
18
|
+
if (dateSpy) {
|
|
19
|
+
dateSpy.mockRestore();
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DateRangePickerOptionsTenantAds,
|
|
3
|
+
DateRangePickerOptionsTenantAdsTypes,
|
|
4
|
+
} from '../date-range-picker-options';
|
|
5
|
+
import { dateMockSpy } from '../__mocks__/date-mock';
|
|
6
|
+
|
|
7
|
+
const newYorkOffset = -240;
|
|
8
|
+
|
|
9
|
+
type OptType = [
|
|
10
|
+
[number, number, number],
|
|
11
|
+
[number, number, number],
|
|
12
|
+
string,
|
|
13
|
+
DateRangePickerOptionsTenantAdsTypes
|
|
14
|
+
];
|
|
15
|
+
const opt = ([sy, sm, sd]: [number, number, number], [ey, em, ed]: [number, number, number]) => ({
|
|
16
|
+
start: new Date(sy, sm - 1, sd),
|
|
17
|
+
end: new Date(ey, em - 1, ed),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const opts = (options: OptType[]) =>
|
|
21
|
+
options.map(([[sy, sm, sd], [ey, em, ed], text, key]) => ({
|
|
22
|
+
key,
|
|
23
|
+
value: opt([sy, sm, sd], [ey, em, ed]),
|
|
24
|
+
text,
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
describe('DateRangePickerOptionsTenantAds', () => {
|
|
28
|
+
const dateSpy = dateMockSpy();
|
|
29
|
+
|
|
30
|
+
const options1: OptType[] = [
|
|
31
|
+
[[2021, 8, 20], [2021, 8, 21], 'Today', 'today'],
|
|
32
|
+
[[2021, 8, 19], [2021, 8, 20], 'Yesterday', 'yesterday'],
|
|
33
|
+
[[2021, 8, 14], [2021, 8, 21], 'Last 7 Days', 'last7'],
|
|
34
|
+
[[2021, 7, 22], [2021, 8, 21], 'Last 30 Days', 'last30'],
|
|
35
|
+
[[2021, 8, 1], [2021, 8, 21], 'Current Month', 'currentMonth'],
|
|
36
|
+
[[2021, 7, 1], [2021, 8, 1], 'Last Month', 'lastMonth'],
|
|
37
|
+
[[2021, 5, 23], [2021, 8, 21], 'Last 90 Days', 'last90'],
|
|
38
|
+
[[2021, 1, 1], [2021, 8, 21], 'Current Year', 'currentYear'],
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
dateSpy.clear();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('works correct when local tz and tenant tz has same dates', () => {
|
|
46
|
+
dateSpy.mock('2021-08-20T12:24:00');
|
|
47
|
+
|
|
48
|
+
const store = DateRangePickerOptionsTenantAds.create({ UtcOffset: newYorkOffset });
|
|
49
|
+
|
|
50
|
+
expect(store.options).toEqual(opts(options1));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('works correct when local tz and tenant tz has different dates', () => {
|
|
54
|
+
dateSpy.mock('2021-08-03T04:24:00');
|
|
55
|
+
|
|
56
|
+
const store = DateRangePickerOptionsTenantAds.create({ UtcOffset: newYorkOffset });
|
|
57
|
+
|
|
58
|
+
expect(store.options).toEqual(
|
|
59
|
+
opts([
|
|
60
|
+
[[2021, 8, 2], [2021, 8, 3], 'Today', 'today'],
|
|
61
|
+
[[2021, 8, 1], [2021, 8, 2], 'Yesterday', 'yesterday'],
|
|
62
|
+
[[2021, 7, 27], [2021, 8, 3], 'Last 7 Days', 'last7'],
|
|
63
|
+
[[2021, 7, 4], [2021, 8, 3], 'Last 30 Days', 'last30'],
|
|
64
|
+
[[2021, 8, 1], [2021, 8, 3], 'Current Month', 'currentMonth'],
|
|
65
|
+
[[2021, 7, 1], [2021, 8, 1], 'Last Month', 'lastMonth'],
|
|
66
|
+
[[2021, 5, 5], [2021, 8, 3], 'Last 90 Days', 'last90'],
|
|
67
|
+
[[2021, 1, 1], [2021, 8, 3], 'Current Year', 'currentYear'],
|
|
68
|
+
])
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('set correct `today` for last day of month', () => {
|
|
73
|
+
dateSpy.mock('2021-08-31T12:24:00');
|
|
74
|
+
|
|
75
|
+
const store = DateRangePickerOptionsTenantAds.create({ UtcOffset: newYorkOffset });
|
|
76
|
+
|
|
77
|
+
expect(store.findOption('today')?.value).toEqual(opt([2021, 8, 31], [2021, 9, 1]));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('set correct `yesterday` for first day of month', () => {
|
|
81
|
+
dateSpy.mock('2021-08-01T12:24:00');
|
|
82
|
+
|
|
83
|
+
const store = DateRangePickerOptionsTenantAds.create({ UtcOffset: newYorkOffset });
|
|
84
|
+
|
|
85
|
+
expect(store.findOption('yesterday')?.value).toEqual(opt([2021, 7, 31], [2021, 8, 1]));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('set correct `lastMonth` for first month of year', () => {
|
|
89
|
+
dateSpy.mock('2021-01-02T12:24:00');
|
|
90
|
+
|
|
91
|
+
const store = DateRangePickerOptionsTenantAds.create({ UtcOffset: newYorkOffset });
|
|
92
|
+
|
|
93
|
+
expect(store.findOption('lastMonth')?.value).toEqual(opt([2020, 12, 1], [2021, 1, 1]));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('findOption', () => {
|
|
97
|
+
options1.forEach(op => {
|
|
98
|
+
it(`'${op[2]}' option should be found`, () => {
|
|
99
|
+
dateSpy.mock('2021-08-20T12:24:00');
|
|
100
|
+
|
|
101
|
+
const store = DateRangePickerOptionsTenantAds.create({ UtcOffset: newYorkOffset });
|
|
102
|
+
|
|
103
|
+
expect(store.findOption(opt(op[0], op[1]))?.key).toEqual(op[3]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('getComparisonDateRange', () => {
|
|
109
|
+
it(`should generate comparison for current month option`, () => {
|
|
110
|
+
dateSpy.mock('2021-08-20T12:24:00');
|
|
111
|
+
|
|
112
|
+
const store = DateRangePickerOptionsTenantAds.create({ UtcOffset: newYorkOffset });
|
|
113
|
+
|
|
114
|
+
expect(store.getComparisonDateRange(store.findOption('currentMonth').value)).toEqual(
|
|
115
|
+
opt([2021, 7, 1], [2021, 8, 1])
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it(`should generate comparison for prev month option`, () => {
|
|
120
|
+
dateSpy.mock('2021-01-20T12:24:00');
|
|
121
|
+
|
|
122
|
+
const store = DateRangePickerOptionsTenantAds.create({ UtcOffset: newYorkOffset });
|
|
123
|
+
|
|
124
|
+
expect(store.getComparisonDateRange(store.findOption('currentMonth').value)).toEqual(
|
|
125
|
+
opt([2020, 12, 1], [2021, 1, 1])
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it(`should generate comparison for custom interval`, () => {
|
|
130
|
+
dateSpy.mock('2021-01-20T12:24:00');
|
|
131
|
+
|
|
132
|
+
const store = DateRangePickerOptionsTenantAds.create({ UtcOffset: newYorkOffset });
|
|
133
|
+
|
|
134
|
+
expect(store.getComparisonDateRange(opt([2021, 3, 5], [2021, 3, 15]))).toEqual(
|
|
135
|
+
opt([2021, 2, 23], [2021, 3, 5])
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { TenantDate } from '../date-tenant';
|
|
2
|
+
import { dateMockSpy } from '../__mocks__/date-mock';
|
|
3
|
+
|
|
4
|
+
describe('TenantDate', () => {
|
|
5
|
+
const dateSpy = dateMockSpy();
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
dateSpy.clear();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('tenantTimeFromLocal', () => {
|
|
12
|
+
it('works correct when local tz and tenant tz has same dates', () => {
|
|
13
|
+
dateSpy.mock('2021-08-20T12:24:00');
|
|
14
|
+
|
|
15
|
+
expect(TenantDate.tenantTimeFromLocal(-240)).toEqual(new Date(2021, 7, 20, 4, 24, 0));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('works correct when local tz and tenant tz has different dates', () => {
|
|
19
|
+
dateSpy.mock('2021-08-03T04:24:00');
|
|
20
|
+
|
|
21
|
+
expect(TenantDate.tenantTimeFromLocal(-240)).toEqual(new Date(2021, 7, 2, 20, 24, 0));
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('tenantDateFromLocal', () => {
|
|
26
|
+
it('works correct when local tz and tenant tz has same dates', () => {
|
|
27
|
+
dateSpy.mock('2021-08-20T12:24:00');
|
|
28
|
+
|
|
29
|
+
expect(TenantDate.tenantDateFromLocal(-240)).toEqual(new Date(2021, 7, 20));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('works correct when local tz and tenant tz has different dates', () => {
|
|
33
|
+
dateSpy.mock('2021-08-03T04:24:00');
|
|
34
|
+
|
|
35
|
+
expect(TenantDate.tenantDateFromLocal(-240)).toEqual(new Date(2021, 7, 2));
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { TenantDate } from './date-tenant';
|
|
2
|
+
import { DateRange, dateRangesEqual, getComparisonDateRange } from './date';
|
|
3
|
+
|
|
4
|
+
export interface DateRangePickerOption<OptionKeys extends string> {
|
|
5
|
+
key: OptionKeys;
|
|
6
|
+
value: DateRange;
|
|
7
|
+
text: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* base class for predefined date range options
|
|
12
|
+
*
|
|
13
|
+
* @note start date is inclusive and end date is exclusive for each option
|
|
14
|
+
*/
|
|
15
|
+
export abstract class DateRangePickerOptions<OptionKeys extends string> {
|
|
16
|
+
options: DateRangePickerOption<OptionKeys>[] = [];
|
|
17
|
+
defaultOption?: OptionKeys;
|
|
18
|
+
|
|
19
|
+
get keys(): OptionKeys[] {
|
|
20
|
+
return this.options.map(o => o.key);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
findOption(key: undefined): undefined;
|
|
24
|
+
findOption(key: OptionKeys): DateRangePickerOption<OptionKeys>;
|
|
25
|
+
findOption(range: DateRange): DateRangePickerOption<OptionKeys> | undefined;
|
|
26
|
+
findOption(arg?: DateRange | OptionKeys): DateRangePickerOption<OptionKeys> | undefined {
|
|
27
|
+
if (!arg) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (typeof arg === 'string') {
|
|
32
|
+
return this.options.find(o => o.key === arg);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const opt of this.options) {
|
|
36
|
+
if (dateRangesEqual(arg, opt.value)) {
|
|
37
|
+
return opt;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
getComparisonDateRange(_range: DateRange): DateRange | undefined {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TenantTimezone {
|
|
50
|
+
UtcOffset: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const subtractDays = (dt: Date, days: number) => new Date(dt.getTime() - days * 86400000);
|
|
54
|
+
|
|
55
|
+
export type DateRangePickerOptionsTenantAdsTypes =
|
|
56
|
+
| 'today'
|
|
57
|
+
| 'yesterday'
|
|
58
|
+
| 'last7'
|
|
59
|
+
| 'last30'
|
|
60
|
+
| 'last90'
|
|
61
|
+
| 'currentMonth'
|
|
62
|
+
| 'lastMonth'
|
|
63
|
+
| 'currentYear';
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* options wrapper in tenant timezone that is used in ads. it contain following options:
|
|
67
|
+
* Today
|
|
68
|
+
* Yesterday
|
|
69
|
+
* Last 7 Days
|
|
70
|
+
* Last 30 Days
|
|
71
|
+
* Current Month
|
|
72
|
+
* Last Month
|
|
73
|
+
* Last 90 Days
|
|
74
|
+
* Current Year
|
|
75
|
+
*/
|
|
76
|
+
export class DateRangePickerOptionsTenantAds extends DateRangePickerOptions<DateRangePickerOptionsTenantAdsTypes> {
|
|
77
|
+
readonly options: DateRangePickerOption<DateRangePickerOptionsTenantAdsTypes>[];
|
|
78
|
+
|
|
79
|
+
private constructor(
|
|
80
|
+
tz: TenantTimezone,
|
|
81
|
+
defaultOption: DateRangePickerOptionsTenantAdsTypes = 'last30'
|
|
82
|
+
) {
|
|
83
|
+
super();
|
|
84
|
+
|
|
85
|
+
const todayTenant = TenantDate.tenantDateFromLocal(tz.UtcOffset);
|
|
86
|
+
|
|
87
|
+
const tomTenant = new Date(
|
|
88
|
+
todayTenant.getFullYear(),
|
|
89
|
+
todayTenant.getMonth(),
|
|
90
|
+
todayTenant.getDate() + 1
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const yestTenant = new Date(
|
|
94
|
+
todayTenant.getFullYear(),
|
|
95
|
+
todayTenant.getMonth(),
|
|
96
|
+
todayTenant.getDate() - 1
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const monTenant = new Date(todayTenant.getFullYear(), todayTenant.getMonth(), 1);
|
|
100
|
+
const lastMonTenant = new Date(todayTenant.getFullYear(), todayTenant.getMonth() - 1, 1);
|
|
101
|
+
const yearTenant = new Date(todayTenant.getFullYear(), 0, 1);
|
|
102
|
+
|
|
103
|
+
this.options = [
|
|
104
|
+
{ key: 'today', value: { start: todayTenant, end: tomTenant }, text: 'Today' },
|
|
105
|
+
{ key: 'yesterday', value: { start: yestTenant, end: todayTenant }, text: 'Yesterday' },
|
|
106
|
+
{
|
|
107
|
+
key: 'last7',
|
|
108
|
+
value: { start: subtractDays(todayTenant, 6), end: tomTenant },
|
|
109
|
+
text: 'Last 7 Days',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
key: 'last30',
|
|
113
|
+
value: { start: subtractDays(todayTenant, 29), end: tomTenant },
|
|
114
|
+
text: 'Last 30 Days',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
key: 'currentMonth',
|
|
118
|
+
value: { start: monTenant, end: tomTenant },
|
|
119
|
+
text: 'Current Month',
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
key: 'lastMonth',
|
|
123
|
+
value: { start: lastMonTenant, end: monTenant },
|
|
124
|
+
text: 'Last Month',
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
key: 'last90',
|
|
128
|
+
value: { start: subtractDays(todayTenant, 89), end: tomTenant },
|
|
129
|
+
text: 'Last 90 Days',
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
key: 'currentYear',
|
|
133
|
+
value: { start: yearTenant, end: tomTenant },
|
|
134
|
+
text: 'Current Year',
|
|
135
|
+
},
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
this.defaultOption = defaultOption;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
static create(
|
|
142
|
+
tz: TenantTimezone,
|
|
143
|
+
defaultOption?: DateRangePickerOptionsTenantAdsTypes
|
|
144
|
+
): DateRangePickerOptionsTenantAds {
|
|
145
|
+
return new DateRangePickerOptionsTenantAds(tz, defaultOption);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
getComparisonDateRange = (range: DateRange) => {
|
|
149
|
+
const option = this.findOption(range);
|
|
150
|
+
|
|
151
|
+
switch (option?.key) {
|
|
152
|
+
case 'currentMonth':
|
|
153
|
+
case 'lastMonth':
|
|
154
|
+
return {
|
|
155
|
+
start: new Date(range.start.getFullYear(), range.start.getMonth() - 1, 1),
|
|
156
|
+
end: range.start,
|
|
157
|
+
};
|
|
158
|
+
case 'currentYear':
|
|
159
|
+
return {
|
|
160
|
+
start: new Date(range.start.getFullYear() - 1, 0, 1),
|
|
161
|
+
end: range.start,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return getComparisonDateRange(range);
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { DateRangePickerOption, DateRangePickerOptions } from './date-range-picker-options';
|
|
2
|
+
import { DateRange } from './date';
|
|
3
|
+
import { action, computed, makeObservable, observable } from 'mobx';
|
|
4
|
+
|
|
5
|
+
interface DateRangePickerStateConfig {
|
|
6
|
+
defaultValue?: DateRange;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface DateRangePickerStateType {
|
|
10
|
+
value?: DateRange;
|
|
11
|
+
onChange(val?: DateRange): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DateRangePickerOptionsStateType<OptionKeys extends string>
|
|
15
|
+
extends DateRangePickerStateType {
|
|
16
|
+
readonly options: DateRangePickerOptions<OptionKeys>;
|
|
17
|
+
readonly selectedOption?: DateRangePickerOption<OptionKeys>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class DateRangePickerState<OptionKeys extends string = never> {
|
|
21
|
+
@observable value?: DateRange;
|
|
22
|
+
|
|
23
|
+
readonly options?: DateRangePickerOptions<OptionKeys>;
|
|
24
|
+
|
|
25
|
+
@computed get selectedOption(): DateRangePickerOption<OptionKeys> | undefined {
|
|
26
|
+
if (!this.value || !this.options) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return this.options.findOption(this.value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
constructor(config?: DateRangePickerStateConfig, options?: DateRangePickerOptions<OptionKeys>) {
|
|
34
|
+
makeObservable(this);
|
|
35
|
+
|
|
36
|
+
this.options = options;
|
|
37
|
+
|
|
38
|
+
if (config?.defaultValue) {
|
|
39
|
+
this.value = config.defaultValue;
|
|
40
|
+
} else if (this.options?.defaultOption) {
|
|
41
|
+
this.value = this.options.findOption(this.options.defaultOption)?.value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
static create(config?: DateRangePickerStateConfig): DateRangePickerStateType {
|
|
46
|
+
return new DateRangePickerState(config);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static createWithOptions<OptionKeys extends string>(
|
|
50
|
+
options: DateRangePickerOptions<OptionKeys>,
|
|
51
|
+
config?: DateRangePickerStateConfig
|
|
52
|
+
): DateRangePickerOptionsStateType<OptionKeys> {
|
|
53
|
+
return new DateRangePickerState(
|
|
54
|
+
config,
|
|
55
|
+
options
|
|
56
|
+
) as DateRangePickerOptionsStateType<OptionKeys>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@action onChange = (val?: DateRange) => {
|
|
60
|
+
this.value = val;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This class exists because we:
|
|
3
|
+
*
|
|
4
|
+
* (1) want to always use tenant TZ (as specified in the settings) over the browser TZ to display/parse dates
|
|
5
|
+
* (2) have parts which we can't/don't want to change right now which use Date (instead of Moment)
|
|
6
|
+
*
|
|
7
|
+
* The issue with Date is that it doesn't support timezones. So we have to introduce the idea of UI Date --
|
|
8
|
+
* a Date value with ticks set to such value that the local time in the current browser TZ is equal to local time in tenant TZ represented by passed real Date
|
|
9
|
+
*
|
|
10
|
+
* Example:
|
|
11
|
+
*
|
|
12
|
+
* Tenant TZ is PDT UTC-07
|
|
13
|
+
* Browser TZ is EDT UTC-04
|
|
14
|
+
*
|
|
15
|
+
* In DB we have a LaunchDate which is 2020-05-11 04:00:00 (we use UTC in DB)
|
|
16
|
+
* This time gives local time of 2020-05-11 00:00:00 in tenant TZ
|
|
17
|
+
* To display the same time in browser's TZ, we need to create a Date with UTC value of 2020-05-11 07:00:00 (see createUIDate)
|
|
18
|
+
* Note that the UTC value of this Date is useless, we only care about local time it gives in that particular TZ, that's why it's an UI date
|
|
19
|
+
* The same reasoning applies when we get a Date from a DatePicker, for example --
|
|
20
|
+
* it's an UI date, we only care about its local time, thus we need to construct a moment which represents the same local time but in tenant TZ (see momentFromUI)
|
|
21
|
+
*
|
|
22
|
+
* If at any point we're able to get rid of all the components relying on Date, this class won't be needed anymore.
|
|
23
|
+
*/
|
|
24
|
+
export class TenantDate {
|
|
25
|
+
/**
|
|
26
|
+
* represent local time in tenant's Timezone
|
|
27
|
+
* @note returned date still have local timezone, so it shouldn't be converted to UTC anywhere
|
|
28
|
+
* @param tenantUtcOffset tenant's timezone offset to UTC (in minutes)
|
|
29
|
+
* @param localDateTime datetime or undefined (for 'now')
|
|
30
|
+
*/
|
|
31
|
+
static tenantTimeFromLocal(tenantUtcOffset: number, localDateTime?: Date): Date {
|
|
32
|
+
const dtLocal = localDateTime ?? new Date();
|
|
33
|
+
const dtUtc = new Date(dtLocal.getTime() + dtLocal.getTimezoneOffset() * 60000);
|
|
34
|
+
|
|
35
|
+
return new Date(dtUtc.getTime() + tenantUtcOffset * 60000);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* represent local date in tenant's Timezone (time values will be 0)
|
|
40
|
+
* @note returned date still have local timezone, so it shouldn't be converted to UTC anywhere
|
|
41
|
+
* @param tenantUtcOffset tenant's timezone offset to UTC (in minutes)
|
|
42
|
+
* @param localDateTime datetime or undefined (for 'now')
|
|
43
|
+
*/
|
|
44
|
+
static tenantDateFromLocal(tenantUtcOffset: number, localDateTime?: Date): Date {
|
|
45
|
+
const dt = TenantDate.tenantTimeFromLocal(tenantUtcOffset, localDateTime);
|
|
46
|
+
|
|
47
|
+
return new Date(dt.getFullYear(), dt.getMonth(), dt.getDate());
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface DateRange {
|
|
2
|
+
start: Date;
|
|
3
|
+
end: Date;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const datesEqual = (date1: Date, date2: Date) =>
|
|
7
|
+
date1.getFullYear() === date2.getFullYear() &&
|
|
8
|
+
date1.getMonth() === date2.getMonth() &&
|
|
9
|
+
date1.getDate() === date2.getDate() &&
|
|
10
|
+
date1.getHours() === date2.getHours() &&
|
|
11
|
+
date1.getMinutes() === date2.getMinutes() &&
|
|
12
|
+
date1.getSeconds() === date2.getSeconds();
|
|
13
|
+
|
|
14
|
+
export const dateRangesEqual = (dateRange1: DateRange, dateRange2: DateRange) =>
|
|
15
|
+
datesEqual(dateRange1.start, dateRange2.start) && datesEqual(dateRange1.end, dateRange2.end);
|
|
16
|
+
|
|
17
|
+
export const getComparisonDateRange = (range: DateRange) => {
|
|
18
|
+
const diff = range.end.getTime() - range.start.getTime();
|
|
19
|
+
const compareStart = new Date(range.start.getTime() - diff);
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
start: new Date(
|
|
23
|
+
compareStart.getFullYear(),
|
|
24
|
+
compareStart.getMonth(),
|
|
25
|
+
compareStart.getDate()
|
|
26
|
+
),
|
|
27
|
+
end: range.start,
|
|
28
|
+
};
|
|
29
|
+
};
|