cronofy-elements 1.39.2 → 1.41.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.
Files changed (42) hide show
  1. package/build/CronofyElements.v1.41.0.js +2 -0
  2. package/build/{CronofyElements.v1.39.2.js.LICENSE.txt → CronofyElements.v1.41.0.js.LICENSE.txt} +0 -0
  3. package/build/npm/CronofyElements.js +2 -2
  4. package/demo/availability-viewer.ejs +8 -0
  5. package/demo/date-time-picker.ejs +2 -0
  6. package/package.json +1 -1
  7. package/src/js/components/AvailabilityViewer/AvailabilityViewer.js +14 -15
  8. package/src/js/components/AvailabilityViewer/Navigation.js +23 -37
  9. package/src/js/components/AvailabilityViewer/WeekWrapper.js +3 -8
  10. package/src/js/components/AvailabilityViewer/contexts/page-context.js +17 -0
  11. package/src/js/components/AvailabilityViewer/contexts/page-reducer.js +28 -0
  12. package/src/js/components/DateTimePicker/Calendar.js +8 -6
  13. package/src/js/components/DateTimePicker/CalendarHeader.js +27 -14
  14. package/src/js/components/DateTimePicker/Confirm.js +8 -6
  15. package/src/js/components/DateTimePicker/DateTimePicker.js +49 -29
  16. package/src/js/components/DateTimePicker/DayButton.js +3 -1
  17. package/src/js/components/DateTimePicker/Details.js +5 -8
  18. package/src/js/components/DateTimePicker/SlotButton.js +6 -6
  19. package/src/js/components/DateTimePicker/TimeZoneSelector.js +25 -29
  20. package/src/js/components/DateTimePicker/Wrapper.js +89 -39
  21. package/src/js/components/DateTimePicker/contexts/status-context.js +3 -3
  22. package/src/js/components/DateTimePicker/contexts/status-reducer.js +90 -74
  23. package/src/js/components/DateTimePicker/contexts/tz-context.js +18 -0
  24. package/src/js/components/DateTimePicker/utils/slots.js +11 -4
  25. package/src/js/helpers/init.DateTimePicker.js +61 -2
  26. package/src/js/helpers/utils.AvailabilityViewer.js +7 -2
  27. package/src/js/helpers/utils.js +7 -1
  28. package/src/js/main.js +17 -1
  29. package/tests/AvailabilityViewer/Navigation.test.js +130 -0
  30. package/tests/AvailabilityViewer/contexts/page-reducer.test.js +87 -0
  31. package/tests/CalendarSync/Active.test.js +1 -1
  32. package/tests/CalendarSync/AddToggle.test.js +1 -1
  33. package/tests/CalendarSync/EditToggle.test.js +1 -1
  34. package/tests/CalendarSync/Inactive.test.js +1 -1
  35. package/tests/CalendarSync/Pending.test.js +1 -1
  36. package/tests/DateTimePicker/SlotButton.test.js +16 -5
  37. package/tests/DateTimePicker/contexts/status-reducer.test.js +277 -322
  38. package/tests/components/main.test.js +8 -1
  39. package/tests/{CalendarSync/mocks → mocks}/i18n.js +0 -0
  40. package/tests/mocks/theme.js +3 -0
  41. package/tests/utils.AvailabilityViewer.test.js +8 -0
  42. package/build/CronofyElements.v1.39.2.js +0 -2
@@ -0,0 +1,18 @@
1
+ import React, { createContext, useContext, useState } from "react";
2
+
3
+ const TzContext = createContext();
4
+
5
+ export const TzProvider = ({ children, options }) => {
6
+ const [tz, setTz] = useState({
7
+ selectedTzid: options.selectedTzid,
8
+ list: options.list,
9
+ });
10
+ return <TzContext.Provider value={[tz, setTz]}>{children}</TzContext.Provider>;
11
+ };
12
+ export const useTz = () => {
13
+ const context = useContext(TzContext);
14
+ if (context === undefined) {
15
+ throw new Error("useTz must be used within a TzProvider");
16
+ }
17
+ return context;
18
+ };
@@ -21,7 +21,7 @@ export const getMonthsCoveredByPeriod = (period, tzid) => {
21
21
  return months;
22
22
  };
23
23
 
24
- export const getMonthsFromQuery = (periods, tzid) => {
24
+ export const getMonthsFromQuery = (periods = [], tzid) => {
25
25
  const months = periods
26
26
  .map(period => getMonthsCoveredByPeriod(period, tzid))
27
27
  // getMonthsCoveredByPeriod returns an array, so flatten them...
@@ -42,9 +42,17 @@ export const getMonthObjectsFromQuery = (query, tzid, current = false) => {
42
42
  if (!query.query_periods || !query.query_periods.length) {
43
43
  return [getCurrentMonth()];
44
44
  }
45
+
45
46
  const monthStrings = getMonthsFromQuery(query.query_periods, tzid);
46
- const currentMonth = current ? current : monthStrings[0];
47
- const monthObjects = monthStrings.map(month => {
47
+ const startMonth = moment(monthStrings[0], "YYYY-MM");
48
+ const endMonth = moment(monthStrings[monthStrings.length - 1], "YYYY-MM");
49
+
50
+ const currentMonth =
51
+ current && moment(current, "YYYY-MM").isBetween(startMonth, endMonth)
52
+ ? current
53
+ : monthStrings[0];
54
+
55
+ return monthStrings.map(month => {
48
56
  const croppedQueryPeriods = cropPeriodsByMonth(query.query_periods, month, tzid);
49
57
  return {
50
58
  month,
@@ -56,7 +64,6 @@ export const getMonthObjectsFromQuery = (query, tzid, current = false) => {
56
64
  },
57
65
  };
58
66
  });
59
- return monthObjects;
60
67
  };
61
68
 
62
69
  export const getSlots = ({ query, auth, tzid, slots = [] }) =>
@@ -1,3 +1,5 @@
1
+ import moment from "moment-timezone";
2
+
1
3
  import {
2
4
  parseConnectionDomains,
3
5
  parseQuery,
@@ -8,6 +10,12 @@ import {
8
10
  validateLocaleModifiers,
9
11
  } from "./init";
10
12
  import { logConstructor } from "./logging";
13
+ import { queryForDateTimePicker } from "./mocks";
14
+
15
+ import {
16
+ getMonthsFromQuery,
17
+ parseQuery as parseWithOverlappingSlots,
18
+ } from "../components/DateTimePicker/utils/slots";
11
19
 
12
20
  export const parseDateTimePickerOptions = (options = {}) => {
13
21
  const config = typeof options.config === "undefined" ? {} : options.config;
@@ -66,10 +74,14 @@ export const parseDateTimePickerOptions = (options = {}) => {
66
74
  const isBookableEventsQuery = options.availability_query.bookable_events ? true : false;
67
75
 
68
76
  let query;
69
- if (isBookableEventsQuery) {
77
+ if (options.demo) {
78
+ query = queryForDateTimePicker;
79
+ } else if (isBookableEventsQuery) {
70
80
  query = options.availability_query;
81
+ query = parseWithOverlappingSlots(query);
71
82
  } else {
72
83
  query = parseQuery({ options, elementSlug: "date-time-picker", log });
84
+ query = parseWithOverlappingSlots(query);
73
85
  }
74
86
 
75
87
  const tzid = parseTimezone(options.tzid, "date-time-picker", log);
@@ -93,6 +105,44 @@ export const parseDateTimePickerOptions = (options = {}) => {
93
105
  });
94
106
  }
95
107
 
108
+ let selectedDate = config.selected_date;
109
+ if (typeof selectedDate !== "undefined") {
110
+ const validDate = moment(selectedDate, "YYYY-MM-DD", true).isValid();
111
+
112
+ if (!validDate) {
113
+ log.warn(
114
+ `The provided date ${selectedDate} is not valid. Please ensure it's formatted like "YYYY-MM-DD". Picking the first available date as starting date.`,
115
+ {
116
+ docsSlug: "#config.start_date",
117
+ }
118
+ );
119
+
120
+ selectedDate = undefined;
121
+ }
122
+ }
123
+
124
+ const months = getMonthsFromQuery(query?.query_periods, tzid.tzid);
125
+ const selectedDateMoment = selectedDate && moment(selectedDate, "YYYY-MM-DD");
126
+
127
+ let startDateMoment = months.length
128
+ ? moment(months[0], "YYYY-MM").startOf("month")
129
+ : moment().startOf("month");
130
+
131
+ if (selectedDateMoment?.isBefore(startDate)) {
132
+ startDateMoment = selectedDateMoment;
133
+ }
134
+
135
+ let endDateMoment = months.length
136
+ ? moment(months[months.length - 1], "YYYY-MM").endOf("month")
137
+ : moment().endOf("month");
138
+
139
+ if (selectedDateMoment?.isAfter(endDateMoment)) {
140
+ endDateMoment = selectedDateMoment;
141
+ }
142
+
143
+ const startDate = startDateMoment.format("YYYY-MM-DD");
144
+ const endDate = endDateMoment.format("YYYY-MM-DD");
145
+
96
146
  delete options.availability_query;
97
147
  delete options.element_token;
98
148
  delete options.target_id;
@@ -107,7 +157,16 @@ export const parseDateTimePickerOptions = (options = {}) => {
107
157
  token,
108
158
  domains,
109
159
  query,
110
- config: { ...config, mode, logs, startDay, tzList },
160
+ config: {
161
+ ...config,
162
+ mode,
163
+ logs,
164
+ startDay,
165
+ selectedDate,
166
+ startDate,
167
+ endDate,
168
+ tzList,
169
+ },
111
170
  translations,
112
171
  log,
113
172
  };
@@ -145,11 +145,16 @@ export const getAllWeekDays = ({ startDate, endDate, startDay = "sunday", tzid }
145
145
  };
146
146
 
147
147
  export const getWeeksInfo = weekdays => {
148
+ const current = 1;
149
+ const total = Math.ceil(weekdays.length / 7);
150
+
148
151
  return {
149
152
  set: true,
150
- current: 1,
151
153
  days: weekdays,
152
- total: Math.ceil(weekdays.length / 7),
154
+ total,
155
+ current,
156
+ hasNext: current < total,
157
+ hasPrev: false,
153
158
  };
154
159
  };
155
160
 
@@ -16,7 +16,13 @@ export const objectToArray = (object, keepKey = true) => {
16
16
  export const truncateString = (str, length = 240) =>
17
17
  str.length <= length - 2 ? str : `${str.slice(0, length)}...`;
18
18
 
19
- export const uniqueItems = array => array.filter((elem, pos, arr) => arr.indexOf(elem) == pos);
19
+ export const uniqueItems = array => {
20
+ if (!Array.isArray(array)) {
21
+ return [];
22
+ }
23
+
24
+ return [...new Set(array)];
25
+ };
20
26
 
21
27
  export const objectIsEmpty = obj => {
22
28
  for (let key in obj) {
package/src/js/main.js CHANGED
@@ -50,23 +50,39 @@ export const SlotPicker = options => {
50
50
  };
51
51
 
52
52
  export const AvailabilityViewer = options => {
53
+ let cb;
54
+
53
55
  // Make sure the correct props are passed to the component
54
56
  const renderElement = (key, options) => (
55
57
  <AvailabilityViewerApp
56
58
  key={key}
57
59
  options={{ ...globalOptionFallbacks, ...options }}
58
60
  error={options.error}
61
+ eventCallback={ecb => (cb = ecb)}
59
62
  />
60
63
  );
61
64
  // Generate and render the component. The `return` is important
62
65
  // here, as it exposes the `update` method (which can be used
63
66
  // after the inital page-load).
64
- return generateElementAPI(
67
+
68
+ const elementApi = generateElementAPI(
65
69
  renderElement,
66
70
  options,
67
71
  parseAvailabilityViewerOptions,
68
72
  "Availability Viewer"
69
73
  );
74
+
75
+ if (!elementApi) {
76
+ return false;
77
+ }
78
+
79
+ return {
80
+ ...elementApi,
81
+ navigate: {
82
+ next: () => cb("SET_NEXT_PAGE"),
83
+ prev: () => cb("SET_PREV_PAGE"),
84
+ },
85
+ };
70
86
  };
71
87
 
72
88
  export const AvailabilityRules = options => {
@@ -0,0 +1,130 @@
1
+ import React from "react";
2
+ import { render, fireEvent } from "@testing-library/react";
3
+
4
+ import { i18n } from "../mocks/i18n";
5
+ import { theme } from "../mocks/theme";
6
+
7
+ import { PagesProvider } from "../../src/js/components/AvailabilityViewer/contexts/page-context";
8
+ import {
9
+ I18nContext,
10
+ LoggingContext,
11
+ StatusContext,
12
+ ThemeContext,
13
+ } from "../../src/js/components/AvailabilityViewer/AvailabilityViewer";
14
+
15
+ import Navigation from "../../src/js/components/AvailabilityViewer/Navigation";
16
+
17
+ const wrapper = ({ children, log, status, pages }) => (
18
+ <LoggingContext.Provider value={log ?? { warn: () => undefined }}>
19
+ <I18nContext.Provider value={i18n}>
20
+ <ThemeContext.Provider value={[theme("AvailabilityViewer")]}>
21
+ <StatusContext.Provider
22
+ value={
23
+ status ?? [
24
+ { pagesLoaded: [], notificationCallback: () => undefined },
25
+ () => undefined,
26
+ ]
27
+ }
28
+ >
29
+ <PagesProvider
30
+ {...(pages
31
+ ? { value: pages[0], dispatch: pages[1] }
32
+ : { value: {}, dispatch: () => undefined })}
33
+ >
34
+ {children}
35
+ </PagesProvider>
36
+ </StatusContext.Provider>
37
+ </ThemeContext.Provider>
38
+ </I18nContext.Provider>
39
+ </LoggingContext.Provider>
40
+ );
41
+
42
+ describe("Navigation", () => {
43
+ it("displays nothing if pages.total is empty", () => {
44
+ const pages = [{ total: 0 }];
45
+
46
+ const { container } = render(<Navigation />, { wrapper: e => wrapper({ ...e, pages }) });
47
+
48
+ expect(container).toBeEmptyDOMElement();
49
+ });
50
+
51
+ it("displays navigation if pages.total > 0", () => {
52
+ const pages = [{ total: 1 }];
53
+
54
+ const { container } = render(<Navigation />, { wrapper: e => wrapper({ ...e, pages }) });
55
+
56
+ const buttonWrapper = container.querySelector(".AvailabilityViewer__navigation");
57
+
58
+ expect(buttonWrapper).toBeInTheDocument();
59
+ });
60
+
61
+ it("disables button is hasNext is false", () => {
62
+ const pages = [{ total: 10, hasNext: false }];
63
+
64
+ const { container } = render(<Navigation />, { wrapper: e => wrapper({ ...e, pages }) });
65
+
66
+ const nextButton = container.querySelector(".AvailabilityViewer__button--next");
67
+
68
+ expect(nextButton).toBeDisabled();
69
+ });
70
+
71
+ it("enables next button if hasNext is true", () => {
72
+ const pages = [{ total: 10, hasNext: true }];
73
+
74
+ const { container } = render(<Navigation />, { wrapper: e => wrapper({ ...e, pages }) });
75
+
76
+ const nextButton = container.querySelector(".AvailabilityViewer__button--next");
77
+
78
+ expect(nextButton).toBeEnabled();
79
+ });
80
+
81
+ it("calls dispatch to set the next page", () => {
82
+ const pagesDispatch = jest.fn();
83
+ const pages = [{ total: 10, hasNext: true }, pagesDispatch];
84
+
85
+ const { container } = render(<Navigation />, { wrapper: e => wrapper({ ...e, pages }) });
86
+
87
+ const nextButton = container.querySelector(".AvailabilityViewer__button--next");
88
+
89
+ fireEvent.click(nextButton);
90
+
91
+ expect(pagesDispatch).toHaveBeenCalledWith({
92
+ type: "SET_NEXT_PAGE",
93
+ });
94
+ });
95
+
96
+ it("disables button if hasPrev is false", () => {
97
+ const pages = [{ total: 10, hasPrev: false }];
98
+
99
+ const { container } = render(<Navigation />, { wrapper: e => wrapper({ ...e, pages }) });
100
+
101
+ const prevButton = container.querySelector(".AvailabilityViewer__button--prev");
102
+
103
+ expect(prevButton).toBeDisabled();
104
+ });
105
+
106
+ it("enables button if hasPrev is true", () => {
107
+ const pages = [{ total: 10, hasPrev: true }];
108
+
109
+ const { container } = render(<Navigation />, { wrapper: e => wrapper({ ...e, pages }) });
110
+
111
+ const prevButton = container.querySelector(".AvailabilityViewer__button--prev");
112
+
113
+ expect(prevButton).toBeEnabled();
114
+ });
115
+
116
+ it("calls dispatch to set the prev page", () => {
117
+ const pagesDispatch = jest.fn();
118
+ const pages = [{ total: 10, hasPrev: true }, pagesDispatch];
119
+
120
+ const { container } = render(<Navigation />, { wrapper: e => wrapper({ ...e, pages }) });
121
+
122
+ const prevButton = container.querySelector(".AvailabilityViewer__button--prev");
123
+
124
+ fireEvent.click(prevButton);
125
+
126
+ expect(pagesDispatch).toHaveBeenCalledWith({
127
+ type: "SET_PREV_PAGE",
128
+ });
129
+ });
130
+ });
@@ -0,0 +1,87 @@
1
+ import { pageReducer } from "../../../src/js/components/AvailabilityViewer/contexts/page-reducer";
2
+
3
+ describe("pageReducer", () => {
4
+ describe("SET_PREV_PAGE", () => {
5
+ it("sets prev page", () => {
6
+ const initialState = {
7
+ current: 5,
8
+ total: 10,
9
+ };
10
+
11
+ const action = {
12
+ type: "SET_PREV_PAGE",
13
+ };
14
+
15
+ const actual = pageReducer(initialState, action);
16
+
17
+ expect(actual).toEqual({
18
+ current: 4,
19
+ total: 10,
20
+ hasNext: true,
21
+ hasPrev: true,
22
+ });
23
+ });
24
+
25
+ it("sets page to 1 if calculated page is less than it", () => {
26
+ const initialState = {
27
+ current: 1,
28
+ total: 10,
29
+ };
30
+
31
+ const action = {
32
+ type: "SET_PREV_PAGE",
33
+ };
34
+
35
+ const actual = pageReducer(initialState, action);
36
+
37
+ expect(actual).toEqual({
38
+ current: 1,
39
+ total: 10,
40
+ hasNext: true,
41
+ hasPrev: false,
42
+ });
43
+ });
44
+ });
45
+
46
+ describe("SET_NEXT_PAGE", () => {
47
+ it("sets next page", () => {
48
+ const initialState = {
49
+ current: 3,
50
+ total: 15,
51
+ };
52
+
53
+ const action = {
54
+ type: "SET_NEXT_PAGE",
55
+ };
56
+
57
+ const actual = pageReducer(initialState, action);
58
+
59
+ expect(actual).toEqual({
60
+ current: 4,
61
+ total: 15,
62
+ hasNext: true,
63
+ hasPrev: true,
64
+ });
65
+ });
66
+
67
+ it("sets page to the total if calculated page is greater than it", () => {
68
+ const initialState = {
69
+ current: 15,
70
+ total: 15,
71
+ };
72
+
73
+ const action = {
74
+ type: "SET_NEXT_PAGE",
75
+ };
76
+
77
+ const actual = pageReducer(initialState, action);
78
+
79
+ expect(actual).toEqual({
80
+ current: 15,
81
+ total: 15,
82
+ hasNext: false,
83
+ hasPrev: true,
84
+ });
85
+ });
86
+ });
87
+ });
@@ -2,7 +2,7 @@ import React from "react";
2
2
  import { render } from "@testing-library/react";
3
3
  import "@testing-library/jest-dom";
4
4
 
5
- import { i18n } from "./mocks/i18n";
5
+ import { i18n } from "../mocks/i18n";
6
6
  import { theme } from "./mocks/theme";
7
7
  import { I18nContext, ThemeContext } from "../../src/js/components/CalendarSync/CalendarSync";
8
8
 
@@ -5,7 +5,7 @@ import {
5
5
  ThemeContext,
6
6
  } from "../../src/js/components/CalendarSync/CalendarSync";
7
7
 
8
- import { i18n } from "./mocks/i18n";
8
+ import { i18n } from "../mocks/i18n";
9
9
  import { theme } from "./mocks/theme";
10
10
 
11
11
  import AddToggle from "../../src/js/components/CalendarSync/AddToggle";
@@ -5,7 +5,7 @@ import {
5
5
  ThemeContext,
6
6
  } from "../../src/js/components/CalendarSync/CalendarSync";
7
7
 
8
- import { i18n } from "./mocks/i18n";
8
+ import { i18n } from "../mocks/i18n";
9
9
  import { theme } from "./mocks/theme";
10
10
 
11
11
  import EditToggle from "../../src/js/components/CalendarSync/EditToggle";
@@ -2,7 +2,7 @@ import React from "react";
2
2
  import { render } from "@testing-library/react";
3
3
  import "@testing-library/jest-dom";
4
4
 
5
- import { i18n } from "./mocks/i18n";
5
+ import { i18n } from "../mocks/i18n";
6
6
  import { theme } from "./mocks/theme";
7
7
  import { I18nContext, ThemeContext } from "../../src/js/components/CalendarSync/CalendarSync";
8
8
 
@@ -2,7 +2,7 @@ import React from "react";
2
2
  import { render } from "@testing-library/react";
3
3
  import "@testing-library/jest-dom";
4
4
 
5
- import { i18n } from "./mocks/i18n";
5
+ import { i18n } from "../mocks/i18n";
6
6
  import { theme } from "./mocks/theme";
7
7
  import { I18nContext, ThemeContext } from "../../src/js/components/CalendarSync/CalendarSync";
8
8
 
@@ -7,6 +7,7 @@ import SlotButton from "../../src/js/components/DateTimePicker/SlotButton";
7
7
  import { I18nProvider } from "../../src/js/contexts/i18n-context";
8
8
  import { ThemeProvider } from "../../src/js/components/DateTimePicker/contexts/theme-context";
9
9
  import { StatusProvider } from "../../src/js/components/DateTimePicker/contexts/status-context";
10
+ import { TzProvider } from "../../src/js/components/DateTimePicker/contexts/tz-context";
10
11
 
11
12
  const wrapper = ({ children, status }) => (
12
13
  <ThemeProvider options={{ name: "DTP" }}>
@@ -17,17 +18,27 @@ const wrapper = ({ children, status }) => (
17
18
  tzid: "Europe/London",
18
19
  }}
19
20
  >
20
- <StatusProvider
21
+ <TzProvider
21
22
  options={{
22
- selected: false,
23
23
  selectedTzid: {
24
24
  tzid: "Europe/London",
25
+ offset: "+01:00",
26
+ offsetMins: 60,
27
+ name: "London",
28
+ abbr: "BST",
25
29
  },
26
- ...status,
30
+ list: [],
27
31
  }}
28
32
  >
29
- {children}
30
- </StatusProvider>
33
+ <StatusProvider
34
+ options={{
35
+ selected: false,
36
+ ...status,
37
+ }}
38
+ >
39
+ {children}
40
+ </StatusProvider>
41
+ </TzProvider>
31
42
  </I18nProvider>
32
43
  </ThemeProvider>
33
44
  );