diff --git a/Courses b/Courses new file mode 100644 index 00000000000..656fb3acdb0 --- /dev/null +++ b/Courses @@ -0,0 +1,53 @@ +\d calendar_events +\d accounts +select auto_subscribe_calendar from Accounts +; +select account_calendar_subscription_type from Accounts; +select account_calendar_subscription_type from Accounts; +select account_calendar_subscription_type from accounts; +\d calendars +Account.find(2) +quit +\q +\d Account +\d Accounts +select id from Accounts where auto_subscribe_account_calendar=true +; +select id from Accounts where Accounts.auto_subscribe_account_calendar=true +; +\t discussion_topics +\t +\t +\d discussion_topics +select id, title, type, context_id, context_type from discussion_topics; +select id, title, type, context_id, context_type, workflow_state from discussion_topics; +select id, title, type, context_id, context_type, workflow_state from assigments; +select id, title, type, context_id, context_type, workflow_state from assignments;; +\d assignment +\d assignments +select id, title, type, context_id, context_type, workflow_state from assignments; +select id, title, context_id, context_type, workflow_state from assignments; +\d assignment; +\d assignments +select submission_type from assignments limit 5; +select submission_types from assignments limit 5; +select uniquer submission_types from assignments; +select unique submission_types from assignments; +select unique(submission_types) from assignments; +select distinct submission_types from assignments; +select distinct external_tool_tag from assignments; +select external_tool_tag from assignments; +\s assignments +\d assignments +\d assignments +\d content_tags +\q +SELECT "access_tokens".* FROM "public"."access_tokens" WHERE "access_tokens"."workflow_state" = 'active' AND "access_tokens"."crypted_token" IN ('2da3562da04da9158d2256c74fabf937df27f024', '724e3eb48297f03d9676f73be0e320b6150e8d12') ORDER BY "access_tokens"."id" ASC LIMIT 1 +; +SELECT "access_tokens".* FROM "public"."access_tokens" WHERE "access_tokens"."workflow_state" = 'active' +; +SELECT "access_tokens".* FROM "public"."access_tokens" WHERE "access_tokens"."workflow_state" = 'active' AND "access_tokens"."crypted_token" IN ('2da3562da04da9158d2256c74fabf937df27f024', '724e3eb48297f03d9676f73be0e320b6150e8d12') ORDER BY "access_tokens"."id" ASC LIMIT 1 +; +\d calendar_events +select cancel_reason from calendar_events; +\s Courses diff --git a/app/controllers/calendar_events_api_controller.rb b/app/controllers/calendar_events_api_controller.rb index 11904701ad9..576136b76be 100644 --- a/app/controllers/calendar_events_api_controller.rb +++ b/app/controllers/calendar_events_api_controller.rb @@ -308,7 +308,7 @@ class CalendarEventsApiController < ApplicationController before_action :require_user_or_observer, only: [:user_index] before_action :require_authorization, only: %w[index user_index] - RECURRING_EVENT_LIMIT = RruleHelper::RECURRING_EVENT_LIMIT + RECURRING_EVENT_LIMIT = 200 DEFAULT_INCLUDES = %w[child_events].freeze @@ -976,7 +976,7 @@ class CalendarEventsApiController < ApplicationController end events = events.to_a - update_limit = rrule_changed ? RECURRING_EVENT_LIMIT : events.length + update_limit = rrule_changed ? RruleHelper::RECURRING_EVENT_LIMIT : events.length error = nil CalendarEvent.skip_touch_context @@ -1817,7 +1817,7 @@ class CalendarEventsApiController < ApplicationController first_start_at = Time.parse(event_attributes[:start_at]) if event_attributes[:start_at] first_end_at = Time.parse(event_attributes[:end_at]) if event_attributes[:end_at] duration = first_end_at - first_start_at if first_start_at && first_end_at - dtstart_list = rrule.all(limit: RECURRING_EVENT_LIMIT) + dtstart_list = rrule.all(limit: RruleHelper::RECURRING_EVENT_LIMIT) InstStatsd::Statsd.gauge("calendar_events_api.recurring.count", dtstart_list.length) @@ -1880,11 +1880,11 @@ class CalendarEventsApiController < ApplicationController end # If RRULE generates a lot of events, rr.count can take a very long time to compute. # Asking it for 1 too many results is fast and gets the job done - if rr.all(limit: RECURRING_EVENT_LIMIT + 1).length > RECURRING_EVENT_LIMIT + if rr.all(limit: RruleHelper::RECURRING_EVENT_LIMIT + 1).length > RruleHelper::RECURRING_EVENT_LIMIT InstStatsd::Statsd.gauge("calendar_events_api.recurring.count_exceeding_limit", rr.count) render json: { message: t("A maximum of %{limit} events may be created", - limit: RECURRING_EVENT_LIMIT) + limit: RruleHelper::RECURRING_EVENT_LIMIT) }, status: :bad_request return nil diff --git a/app/helpers/rrule_helper.rb b/app/helpers/rrule_helper.rb index c6db90ee303..125c6d44133 100644 --- a/app/helpers/rrule_helper.rb +++ b/app/helpers/rrule_helper.rb @@ -26,7 +26,7 @@ end # rubocop:disable Style/IfInsideElse module RruleHelper - RECURRING_EVENT_LIMIT = 200 + RECURRING_EVENT_LIMIT = 400 def rrule_to_natural_language(rrule) rropts = rrule_parse(rrule) diff --git a/package.json b/package.json index b0b159bd369..d28663d1d85 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "@instructure/ui-tooltip": "^7", "@instructure/ui-tray": "^7", "@instructure/ui-tree-browser": "^7", + "@instructure/ui-utils": "^7", "@instructure/ui-view": "^7", "@instructure/uid": "^7", "@instructure/updown": "^1.3", diff --git a/spec/apis/v1/calendar_events_api_spec.rb b/spec/apis/v1/calendar_events_api_spec.rb index 38b5dd75c17..80097e85bce 100644 --- a/spec/apis/v1/calendar_events_api_spec.rb +++ b/spec/apis/v1/calendar_events_api_spec.rb @@ -1672,7 +1672,7 @@ describe CalendarEventsApiController, type: :request do title: "ohai", start_at: start_at.iso8601, end_at: end_at.iso8601, - rrule: "FREQ=WEEKLY;INTERVAL=1;COUNT=201" + rrule: "FREQ=WEEKLY;INTERVAL=1;COUNT=401" } } ) diff --git a/ui/shared/calendar/react/RecurringEvents/CustomRecurrence/CustomRecurrence.stories.tsx b/ui/shared/calendar/react/RecurringEvents/CustomRecurrence/CustomRecurrence.stories.tsx new file mode 100644 index 00000000000..4bd67d33ff6 --- /dev/null +++ b/ui/shared/calendar/react/RecurringEvents/CustomRecurrence/CustomRecurrence.stories.tsx @@ -0,0 +1,138 @@ +// @ts-nocheck +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import React, {useCallback, useEffect, useState} from 'react' +import {Story, Meta} from '@storybook/react' +import moment from 'moment-timezone' +import {Text} from '@instructure/ui-text' +import {View} from '@instructure/ui-view' +import CustomRecurrence, {CustomRecurrenceProps} from './CustomRecurrence' +import RRuleHelper, {RRuleHelperSpec, ISODateToIcalDate} from '../RRuleHelper' + +export default { + title: 'Examples/Calendar/RecurringEvents/CustomRecurrence', + component: CustomRecurrence, +} as Meta + +const specToRule = (spec: RRuleHelperSpec): string => { + try { + const rrule = new RRuleHelper(spec) + return rrule.toString() + } catch (e) { + return e.message + } +} + +const makeValidEventStart = (eventStart: string, timezone: string): string => { + const es = !eventStart ? moment().tz(timezone) : moment.tz(eventStart, timezone) + if (es.isValid()) return es.format('YYYY-MM-DDTHH:mm:ssZ') + return moment().tz(timezone).format('YYYY-MM-DDTHH:mm:ssZ') +} + +const Template: Story = args => { + const {RRULE = '', eventStart, timezone, locale} = args + const [currRRULESpec, setCurrRRULESpec] = useState( + new RRuleHelper(RRuleHelper.parseString(RRULE)).spec + ) + const [currEventStart, setCurrEventStart] = useState(() => { + return makeValidEventStart(eventStart, timezone) + }) + + useEffect(() => { + setCurrEventStart(makeValidEventStart(eventStart, timezone)) + }, [timezone, eventStart]) + + useEffect(() => { + setCurrRRULESpec(new RRuleHelper(RRuleHelper.parseString(RRULE)).spec) + }, [RRULE]) + + const handleChange = useCallback((newSpec: RRuleHelperSpec) => { + setCurrRRULESpec(newSpec) + }, []) + + return ( +
+ + + + + + +
+ eventStart: {currEventStart} + result: {specToRule(currRRULESpec)} +
+
+ ) +} + +const mytimezone = Intl.DateTimeFormat().resolvedOptions().timeZone + +export const Default = Template.bind({}) +Default.args = { + locale: 'en', + timezone: mytimezone, + // RRULE: 'FREQ=DAILY;INTERVAL=1;COUNT=3', + // RRULE: 'FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=1;UNTIL=20240501T000000Z', + // RRULE: 'FREQ=MONTHLY;BYSETPOS=1;BYDAY=MO;INTERVAL=1;UNTIL=20240501T000000Z', // monthly on the first Monday + // RRULE: 'FREQ=MONTHLY;BYMONTHDAY=2;INTERVAL=1;UNTIL=20240501T000000Z', // monthly on the 2nd of the month + RRULE: 'FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=7;COUNT=4', // yearly on Feb 7 +} + +export const InGBEnglish = Template.bind({}) +InGBEnglish.args = { + locale: 'en-GB', + timezone: mytimezone, + RRULE: 'FREQ=DAILY;INTERVAL=1;COUNT=3', +} + +export const WithEventStart = Template.bind({}) +WithEventStart.args = { + locale: 'en', + timezone: 'America/Los_Angeles', + eventStart: moment().tz('America/Los_Angeles').add(-1, 'month').toISOString(true), + RRULE: '', +} + +const untilTheFuture = ISODateToIcalDate(moment().tz(mytimezone).add(9, 'months').toISOString(true)) +export const InitWithUntil = Template.bind({}) +InitWithUntil.args = { + locale: 'en', + timezone: mytimezone, + RRULE: `FREQ=MONTHLY;BYMONTHDAY=1;INTERVAL=1;UNTIL=${untilTheFuture}`, +} + +export const WithNoRRULE = Template.bind({}) +WithNoRRULE.args = { + local: 'en', + timezone: mytimezone, + RRULE: '', +} diff --git a/ui/shared/calendar/react/RecurringEvents/CustomRecurrence/CustomRecurrence.tsx b/ui/shared/calendar/react/RecurringEvents/CustomRecurrence/CustomRecurrence.tsx new file mode 100644 index 00000000000..4cea27b5a79 --- /dev/null +++ b/ui/shared/calendar/react/RecurringEvents/CustomRecurrence/CustomRecurrence.tsx @@ -0,0 +1,182 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import React, {useCallback, useEffect, useState} from 'react' +import moment from 'moment-timezone' +import {FrequencyValue, RRULEDayValue, UnknownSubset} from '../types' +import RRuleHelper, {RRuleHelperSpec} from '../RRuleHelper' +import RepeatPicker, {OnRepeatPickerChangeType} from '../RepeatPicker/RepeatPicker' +import RecurrenceEndPicker, { + OnRecurrenceEndChangeType, +} from '../RecurrenceEndPicker/RecurrenceEndPicker' +import {View} from '@instructure/ui-view' + +export type CustomRecurrenceProps = { + locale: string + timezone: string + eventStart: string + courseEndAt?: string + rruleSpec: RRuleHelperSpec + onChange: (newSpec: RRuleHelperSpec) => void +} + +type RRULESpecOverride = UnknownSubset + +type StateToSpecFunc = (overrides: RRULESpecOverride) => RRuleHelperSpec + +function startToString(dtstart: string | null, timezone: string): string { + const start = dtstart == null ? moment().tz(timezone) : moment.tz(dtstart, timezone) + if (start.isValid()) return start.format('YYYY-MM-DDTHH:mm:ssZ') + throw new Error('eventStart is not a valid date') +} + +// NOTE: you can get some weird results if the rrule isn't in sync with the eventStart +// For example, event start is on July 4, but the rrule says +// FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=7 (i.e. Feb 7) +export default function CustomRecurrence({ + locale, + timezone, + eventStart, + courseEndAt, + rruleSpec, + onChange, +}: CustomRecurrenceProps) { + const [rrule_obj, set_rrrule_obj] = useState(new RRuleHelper(rruleSpec)) + + const [freq, setFreq] = useState(rrule_obj.spec.freq) + const [interval, setInterval] = useState(rrule_obj.spec.interval) + const [weekdays, setWeekdays] = useState(rrule_obj.spec.weekdays) + const [month, setMonth] = useState(rrule_obj.spec.month) + const [monthdate, setMonthdate] = useState(rrule_obj.spec.monthdate) + const [pos, setPos] = useState(rrule_obj.spec.pos) + const [count, setCount] = useState(rrule_obj.spec.count) + const [until, setUntil] = useState(rrule_obj.spec.until) + const [dtstart_str, setDtstartStr] = useState(startToString(eventStart, timezone)) + + const stateToSpec = useCallback( + (overrides: RRULESpecOverride) => { + const currSpec = { + freq, + interval, + weekdays, + monthdate, + pos, + month, + count, + until, + } + return new RRuleHelper({...currSpec, ...overrides}).spec + }, + [count, freq, interval, month, monthdate, pos, until, weekdays] + ) + + const fireOnChange = useCallback( + (spec: RRuleHelperSpec) => { + onChange(spec) + }, + [onChange] + ) + + useEffect(() => { + const eventStartMoment = moment(eventStart) + if (!eventStartMoment.isValid()) { + throw new Error('eventStart is not a valid date') + } + setDtstartStr(startToString(eventStart, timezone)) + let updatedSpec + if (typeof rrule_obj.spec.month === 'number') { + if (eventStartMoment.month() + 1 !== rrule_obj.spec.month) { + updatedSpec = {...rrule_obj.spec, month: eventStartMoment.month() + 1} + setMonth(updatedSpec.month) + } + if (eventStartMoment.date() !== rrule_obj.spec.monthdate) { + updatedSpec = updatedSpec || {...rrule_obj.spec} + updatedSpec.monthdate = eventStartMoment.date() + setMonthdate(updatedSpec.monthdate) + } + } + if (updatedSpec) { + fireOnChange(updatedSpec) + } + }, [eventStart, fireOnChange, rrule_obj.spec, timezone]) + + useEffect(() => { + set_rrrule_obj(new RRuleHelper(rruleSpec)) + }, [rruleSpec]) + + useEffect(() => { + setFreq(rrule_obj.spec.freq) + setInterval(rrule_obj.spec.interval) + setWeekdays(rrule_obj.spec.weekdays) + setMonthdate(rrule_obj.spec.monthdate) + setMonth(rrule_obj.spec.month) + setPos(rrule_obj.spec.pos) + setDtstartStr(startToString(dtstart_str, timezone)) + setCount(rrule_obj.spec.count) + setUntil(rrule_obj.spec.until) + }, [dtstart_str, rrule_obj, timezone]) + + const handleFrequencyChange = useCallback( + (newFreqSpec: OnRepeatPickerChangeType) => { + setFreq(newFreqSpec.freq) + setInterval(newFreqSpec.interval) + setWeekdays(newFreqSpec.weekdays) + setMonthdate(newFreqSpec.monthdate) + setPos(newFreqSpec.pos) + fireOnChange(stateToSpec(newFreqSpec)) + }, + [fireOnChange, stateToSpec] + ) + + const handleEndChange = useCallback( + (endspec: OnRecurrenceEndChangeType) => { + setCount(endspec.count) + setUntil(endspec.until) + fireOnChange(stateToSpec({count: endspec.count, until: endspec.until})) + }, + [fireOnChange, stateToSpec] + ) + + return ( + + + + + + + + + ) +} diff --git a/ui/shared/calendar/react/RecurringEvents/CustomRecurrence/__tests__/CustomRecurrence.test.tsx b/ui/shared/calendar/react/RecurringEvents/CustomRecurrence/__tests__/CustomRecurrence.test.tsx new file mode 100644 index 00000000000..f46648b1311 --- /dev/null +++ b/ui/shared/calendar/react/RecurringEvents/CustomRecurrence/__tests__/CustomRecurrence.test.tsx @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import React from 'react' +import {render, act, fireEvent} from '@testing-library/react' +import moment from 'moment-timezone' +import {UnknownSubset} from '../../types' +import {RRuleHelperSpec} from '../../RRuleHelper' +import { + formatDate, + makeSimpleIsoDate, + changeUntilDate, +} from '../../RecurrenceEndPicker/__tests__/RecurrenceEndPicker.test' +import {changeFreq} from '../../RepeatPicker/__tests__/RepeatPicker.test' +import {weekdaysFromMoment} from '../../RepeatPicker/RepeatPicker' +import CustomRecurrence, {CustomRecurrenceProps} from '../CustomRecurrence' + +const defaultTZ = 'Asia/Tokyo' +const today = moment().tz(defaultTZ) + +const defaultProps = ( + overrides: UnknownSubset = {}, + specOverrides: UnknownSubset = {} +): CustomRecurrenceProps => ({ + locale: 'en', + timezone: defaultTZ, + eventStart: makeSimpleIsoDate(today), + courseEndAt: undefined, + rruleSpec: { + freq: 'DAILY', + interval: 1, + weekdays: undefined, + month: undefined, + monthdate: undefined, + pos: undefined, + until: undefined, + count: undefined, + ...specOverrides, + }, + onChange: () => {}, + ...overrides, +}) + +describe('CustomRecurrence', () => { + beforeEach(() => { + moment.tz.setDefault(defaultTZ) + }) + + it('renders', () => { + const props = {...defaultProps()} + const {getByText, getAllByText, getByDisplayValue} = render() + + expect(getByText('Repeat every:')).toBeInTheDocument() + + // interval + expect(getByText('every')).toBeInTheDocument() + expect(getByDisplayValue('1')).toBeInTheDocument() + // frequency + expect(getByText('date')).toBeInTheDocument() + expect(getByDisplayValue('Day')).toBeInTheDocument() + + expect(getAllByText('Ends:').length).toBeGreaterThan(0) + // the radio buttons + expect(getByText('on')).toBeInTheDocument() + expect(getByDisplayValue('ON')).toBeInTheDocument() + expect(getByText('after')).toBeInTheDocument() + expect(getByDisplayValue('AFTER')).toBeInTheDocument() + + // count + expect(getByDisplayValue('5')).toBeInTheDocument() + + // until + expect(getByText('date')).toBeInTheDocument() + const until = formatDate(today.clone().add(1, 'year').toDate(), props.locale, props.timezone) + expect(getByDisplayValue(until)).toBeInTheDocument() + }) + + it('fires onChange when interval changes', () => { + const onChange = jest.fn() + const props = {...defaultProps({onChange}, {count: 5})} + const {getByDisplayValue} = render() + + const interval = getByDisplayValue('1') + fireEvent.change(interval, {target: {value: '2'}}) + + expect(onChange).toHaveBeenCalledWith({ + ...props.rruleSpec, + interval: 2, + }) + }) + + it('fires onChange when freq changes', () => { + const onChange = jest.fn() + const props = {...defaultProps({onChange}, {count: 5})} + render() + + changeFreq('Day', 'Week') + + expect(onChange).toHaveBeenCalledWith({ + ...props.rruleSpec, + freq: 'WEEKLY', + weekdays: weekdaysFromMoment(today), + }) + }) + + it('fires onChange when count changes', () => { + const onChange = jest.fn() + const props = {...defaultProps({onChange}, {count: 5})} + const {getByDisplayValue} = render() + + const countinput = getByDisplayValue('5') + act(() => { + fireEvent.change(countinput, {target: {value: '6'}}) + }) + + expect(onChange).toHaveBeenCalledWith({ + ...props.rruleSpec, + count: 6, + }) + }) + + it('fires onChange when until changes', async () => { + const onChange = jest.fn() + const endDate = today.clone().add(1, 'year') + const props = {...defaultProps({onChange}, {until: makeSimpleIsoDate(endDate)})} + render() + + const newEndDate = today.clone().add(2, 'year') + await changeUntilDate(endDate, newEndDate, props.locale, props.timezone) + + expect(onChange).toHaveBeenCalledWith({ + ...props.rruleSpec, + until: newEndDate.startOf('day').format('YYYY-MM-DDTHH:mm:ssZ'), + }) + }) + + it('fires onChange when end type changes', () => { + const onChange = jest.fn() + const props = {...defaultProps({onChange}, {count: 5})} + const {getByDisplayValue} = render() + + const after = getByDisplayValue('ON') + fireEvent.click(after) + + expect(onChange).toHaveBeenCalledWith({ + ...props.rruleSpec, + count: undefined, + until: today.clone().add(1, 'year').startOf('day').format('YYYY-MM-DDTHH:mm:ssZ'), + }) + }) +}) diff --git a/ui/shared/calendar/react/RecurringEvents/CustomRecurrenceModal/CustomRecurrenceModal.stories.tsx b/ui/shared/calendar/react/RecurringEvents/CustomRecurrenceModal/CustomRecurrenceModal.stories.tsx new file mode 100644 index 00000000000..b7ec51f1161 --- /dev/null +++ b/ui/shared/calendar/react/RecurringEvents/CustomRecurrenceModal/CustomRecurrenceModal.stories.tsx @@ -0,0 +1,110 @@ +// @ts-nocheck +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import React, {useCallback, useState} from 'react' +import moment from 'moment-timezone' +import {Story, Meta} from '@storybook/react' +import {Button} from '@instructure/ui-buttons' +import {Text} from '@instructure/ui-text' +import {View} from '@instructure/ui-view' +import CustomRecurrenceModal, {CustomRecurrenceModalProps} from './CustomRecurrenceModal' +import {ISODateToIcalDate} from '../RRuleHelper' + +export default { + title: 'Examples/Calendar/RecurringEvents/CustomRecurrenceModal', + component: CustomRecurrenceModal, +} as Meta + +const Template: Story = args => { + const {RRULE = ''} = args + const [currRRULE, setCurrRRULE] = useState(RRULE) + const [isModalOpen, setIsModalOpen] = useState(true) + + const handleChange = useCallback((newRRULE: string | null) => { + setCurrRRULE(newRRULE) + setIsModalOpen(false) + }, []) + + return ( +
+ + + + { + setIsModalOpen(false) + }} + onSave={handleChange} + /> +
+ eventStart: {args.eventStart} + result: {currRRULE} +
+
+ ) +} + +const mytimezone = Intl.DateTimeFormat().resolvedOptions().timeZone + +export const Default = Template.bind({}) +const defaultEventStart = moment.tz('2023-07-04', mytimezone) + +Default.args = { + eventStart: moment().tz(mytimezone).toISOString(true), + locale: 'en', + timezone: mytimezone, + RRULE: '', +} + +// event starts on july 4, but the rrule says repeat every feb 7 +export const OutOfSyncEventStart = Template.bind({}) +OutOfSyncEventStart.args = { + eventStart: moment.tz('2023-07-04', mytimezone).toISOString(true), + locale: 'en', + timezone: mytimezone, + RRULE: 'FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=7;COUNT=3', +} + +export const WithCourseEnd = Template.bind({}) +WithCourseEnd.args = { + locale: 'en', + timezone: mytimezone, + RRULE: 'FREQ=DAILY;INTERVAL=1;COUNT=3', + eventStart: moment.tz(mytimezone).toISOString(true), + courseEndAt: moment.tz(mytimezone).add(18, 'months').toISOString(true), +} + +export const YearlyJuly4 = Template.bind({}) +YearlyJuly4.args = { + local: 'en', + timezone: mytimezone, + eventStart: moment.tz('2023-07-04', mytimezone).toISOString(true), + // RRULE: 'FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=1;UNTIL=20240501T000000Z', + // RRULE: 'FREQ=MONTHLY;BYSETPOS=1;BYDAY=MO;INTERVAL=1;UNTIL=20240501T000000Z', // monthly on the first Monday + // RRULE: 'FREQ=MONTHLY;BYMONTHDAY=2;INTERVAL=1;UNTIL=20240501T000000Z', // monthly on the 2nd of the month + RRULE: `FREQ=YEARLY;INTERVAL=1;BYMONTH=7;BYMONTHDAY=4;UNTIL=${ISODateToIcalDate( + defaultEventStart.clone().add(5, 'years').toISOString(true) + )}`, +} diff --git a/ui/shared/calendar/react/RecurringEvents/CustomRecurrenceModal/CustomRecurrenceModal.tsx b/ui/shared/calendar/react/RecurringEvents/CustomRecurrenceModal/CustomRecurrenceModal.tsx new file mode 100644 index 00000000000..2ffa1b1aeb3 --- /dev/null +++ b/ui/shared/calendar/react/RecurringEvents/CustomRecurrenceModal/CustomRecurrenceModal.tsx @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import React, {useCallback, useEffect, useState} from 'react' +import CanvasModal from '@canvas/instui-bindings/react/Modal' +import {useScope} from '@canvas/i18n' +import {Button} from '@instructure/ui-buttons' +import CustomRecurrence from '../CustomRecurrence/CustomRecurrence' +import RRuleHelper, {RRuleHelperSpec} from '../RRuleHelper' + +const I18n = useScope('calendar_custom_recurring_event_custom_recurrence_modal') + +const isValid = (spec: RRuleHelperSpec): boolean => { + const rrule = new RRuleHelper(spec) + return rrule.isValid() +} + +type CustomRecurrenceErrorState = { + hasError: boolean + errorMessage: string +} + +class CustomRecurrenceErrorBoundary extends React.Component { + state: CustomRecurrenceErrorState + + constructor(props: any) { + super(props) + this.state = { + hasError: false, + errorMessage: '', + } + } + + static getDerivedStateFromError(error: Error) { + return { + hasError: true, + errorMessage: error.message, + } + } + + render() { + if (this.state.hasError) { + return ( +
+

{I18n.t('There was an error loading the custom recurrence editor')}

+

{this.state.errorMessage}

+
+ ) + } + return this.props.children + } +} + +export type CustomRecurrenceModalProps = { + eventStart: string + locale: string + timezone: string + courseEndAt?: string + RRULE: string + isOpen: boolean + onDismiss: () => void + onSave: (RRULE: string) => void +} + +export default function CustomRecurrenceModal({ + eventStart, + locale, + timezone, + courseEndAt, + RRULE, + isOpen, + onDismiss, + onSave, +}: CustomRecurrenceModalProps) { + const [currSpec, setCurrSpec] = useState(() => { + return new RRuleHelper(RRuleHelper.parseString(RRULE)).spec + }) + const [isValidState, setIsValidState] = useState(() => isValid(currSpec)) + + useEffect(() => { + setIsValidState(isValid(currSpec)) + }, [currSpec]) + + const handleChange = useCallback((newSpec: RRuleHelperSpec) => { + setCurrSpec(newSpec) + }, []) + + const handleSave = useCallback(() => { + const rrule = new RRuleHelper(currSpec).toString() + onSave(rrule) + }, [currSpec, onSave]) + + const Footer = useCallback(() => { + return ( + <> + + + + ) + }, [handleSave, isValidState, onDismiss]) + + return ( + } + shouldCloseOnDocumentClick={false} + > + + + + + ) +} diff --git a/ui/shared/calendar/react/RecurringEvents/CustomRecurrenceModal/__tests__/CustomRecurrenceModal.test.tsx b/ui/shared/calendar/react/RecurringEvents/CustomRecurrenceModal/__tests__/CustomRecurrenceModal.test.tsx new file mode 100644 index 00000000000..b13affef383 --- /dev/null +++ b/ui/shared/calendar/react/RecurringEvents/CustomRecurrenceModal/__tests__/CustomRecurrenceModal.test.tsx @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import React from 'react' +import moment from 'moment-timezone' +import {render, fireEvent} from '@testing-library/react' +import {UnknownSubset} from '../../types' +import CustomRecurrenceModal, {CustomRecurrenceModalProps} from '../CustomRecurrenceModal' + +const defaultTZ = 'Asia/Tokyo' + +const defaultProps = ( + overrides: UnknownSubset = {} +): CustomRecurrenceModalProps => ({ + eventStart: '2021-01-01T00:00:00.000Z', + locale: 'en', + timezone: defaultTZ, + courseEndAt: undefined, + RRULE: 'RRULE:FREQ=DAILY;INTERVAL=1;COUNT=5', + isOpen: true, + onDismiss: () => {}, + onSave: () => {}, + ...overrides, +}) + +describe('CustomRecurrenceModal', () => { + beforeAll(() => { + moment.tz.setDefault(defaultTZ) + }) + + it('renders', () => { + const {getByText, getByTestId} = render() + + expect(getByText('Custom Recurrence')).toBeInTheDocument() + expect(getByTestId('custom-recurrence')).toBeInTheDocument() + }) + + it('calls onDismiss when the close button is clicked', () => { + const onDismiss = jest.fn() + const {getByText} = render() + + getByText('Close').click() + + expect(onDismiss).toHaveBeenCalled() + }) + + it('calls onDismiss when the cancel button is clicked', () => { + const onDismiss = jest.fn() + const {getByText} = render() + + getByText('Cancel').click() + + expect(onDismiss).toHaveBeenCalled() + }) + + it('calls onSave when the Done button is clicked', () => { + const onSave = jest.fn() + const {getByText} = render() + + getByText('Done').click() + + expect(onSave).toHaveBeenCalledWith('FREQ=DAILY;INTERVAL=1;COUNT=5') + }) + + it('calls onSave witn an updated RRULE', () => { + const onSave = jest.fn() + const {getByText, getByDisplayValue} = render( + + ) + + const interval = getByDisplayValue('1') + fireEvent.change(interval, {target: {value: '2'}}) + getByText('Done').click() + + expect(onSave).toHaveBeenCalledWith('FREQ=DAILY;INTERVAL=2;COUNT=5') + }) +}) diff --git a/ui/shared/calendar/react/RecurringEvents/RRuleHelper.ts b/ui/shared/calendar/react/RecurringEvents/RRuleHelper.ts new file mode 100644 index 00000000000..1dc3e386303 --- /dev/null +++ b/ui/shared/calendar/react/RecurringEvents/RRuleHelper.ts @@ -0,0 +1,249 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +// A utility for creating and parsing RRULEs +// 1. Given the parameters of a recurring event and an RRuleHelperSpec, +// create an RRuleHelper object which can then be used to generate +// an RRULE string. +// 2. Parse an RRULE string into an RRuleHelperSpec. + +import moment from 'moment-timezone' +import {FrequencyValue, MonthlyModeValue, SelectedDaysArray, UnknownSubset} from './types' + +export const DEFAULT_COUNT = 5 +export const MAX_COUNT = 400 // keep in sync with RECURRING_EVENT_LIMIT in app/helpers/rrule_helper.rb + +export type RRuleHelperSpec = { + freq: FrequencyValue + interval: number + weekdays?: SelectedDaysArray + month?: number + monthdate?: number + pos?: number + until?: string + count?: number +} + +// Given an iCalendar formatted date-time string, return +// an ISO 8601 formatted string. +export const icalDateToISODate = (icalDate: string): string => { + return moment(icalDate).utc().format() +} + +// Given an ISO 8601 formatted date-time string, return +// an iCalendar formatted string. +export const ISODateToIcalDate = (isoDate: string): string => { + return moment(isoDate).utc().format('YYYYMMDDTHHmmss[Z]') +} + +const makeEmptySpec = (more: UnknownSubset = {}): RRuleHelperSpec => ({ + freq: 'DAILY', + interval: 1, + ...more, +}) + +export default class RRuleHelper { + // the parameters describing the recurrence + spec: RRuleHelperSpec + + // Create an RRuleHelper object from an RRuleHelperSpec + constructor(spec: RRuleHelperSpec) { + this.spec = spec + } + + // Parse an RRULE string into an RRuleHelperSpec + static parseString(rrule_str: string = ''): RRuleHelperSpec { + if (rrule_str.length === 0) { + // guarantee what return is valid + return makeEmptySpec({count: DEFAULT_COUNT}) + } + + const keys = [ + 'FREQ', + 'INTERVAL', + 'BYDAY', + 'BYMONTH', + 'BYMONTHDAY', + 'BYSETPOS', + 'UNTIL', + 'UNTIL;TZID', + 'COUNT', + ] + const restr = keys.map(k => `(${k}=[^;]+)`).join('|') + const re = new RegExp(restr, 'g') + const matches = [...rrule_str.matchAll(re)] + const spec: RRuleHelperSpec = makeEmptySpec() + for (const value of matches) { + const [key, val] = value[0].split('=') + switch (key) { + case 'FREQ': + spec.freq = val as FrequencyValue + break + case 'INTERVAL': + spec.interval = parseInt(val, 10) + break + case 'BYDAY': + spec.weekdays = val.split(',') as SelectedDaysArray + break + case 'BYMONTH': + spec.month = parseInt(val, 10) + break + case 'BYMONTHDAY': + spec.monthdate = parseInt(val, 10) + break + case 'BYSETPOS': + spec.pos = parseInt(val, 10) + break + case 'UNTIL': + spec.until = icalDateToISODate(val) + break + case 'COUNT': + spec.count = parseInt(val, 10) + break + default: + throw new Error(`Unknown key: ${key}`) + } + } + return spec + } + + // Generate an RRULE string from an RRuleHelper + toString(): string { + if (!this.isValid()) throw new Error('Invalid RRULE spec') + switch (this.spec.freq) { + case 'DAILY': + return this.daily() + case 'WEEKLY': + return this.weekly() + case 'MONTHLY': + return this.monthly() + case 'YEARLY': + return this.yearly() + default: + throw new Error(`Unknown frequency: ${this.spec.freq}`) + } + } + + hasValidEnd(): boolean { + return ( + (typeof this.spec.count === 'number' && this.spec.count > 0) || + (typeof this.spec.until === 'string' && moment(this.spec.until).isValid()) + ) + } + + hasValidInterval(): boolean { + return typeof this.spec.interval === 'number' && this.spec.interval > 0 + } + + hasValidWeekdays(): boolean { + return Array.isArray(this.spec.weekdays) && this.spec.weekdays.length > 0 + } + + hasValidPos(): boolean { + return typeof this.spec.pos === 'number' && this.spec.pos > 0 + } + + isValid(): boolean { + if (!this.hasValidEnd()) return false + if (!this.hasValidInterval()) return false + + switch (this.spec.freq) { + case 'DAILY': + return typeof this.spec.interval === 'number' && this.spec.interval > 0 + case 'WEEKLY': + return this.hasValidWeekdays() + case 'MONTHLY': + return this.spec.monthdate !== undefined || this.spec.pos !== undefined + case 'YEARLY': + return ( + (this.spec.monthdate !== undefined && this.spec.month !== undefined) || + (this.spec.month !== undefined && this.hasValidWeekdays() && this.hasValidPos()) + ) + default: + return false + } + } + + monthlyMode(): MonthlyModeValue | undefined { + if (this.spec.freq !== 'MONTHLY') { + return undefined + } + if (this.spec.monthdate !== undefined) { + return 'BYMONTHDATE' + } else if ( + Array.isArray(this.spec.weekdays) && + this.spec.weekdays.length > 0 && + typeof this.spec.pos === 'number' + ) { + return 'BYMONTHDAY' + } else { + return undefined + } + } + + untilOrCount(until: string | undefined, count: number | undefined): string { + if (until !== undefined) { + return `;UNTIL=${ISODateToIcalDate(until)}` + } else if (count !== undefined) { + return `;COUNT=${count}` + } else { + return '' + } + } + + daily() { + const {interval, until, count} = this.spec + const endoptions = this.untilOrCount(until, count) + return `FREQ=DAILY;INTERVAL=${interval}${endoptions}` + } + + weekly() { + const {interval, weekdays, until, count} = this.spec + if (weekdays === undefined) { + throw new Error("Weekly recurrence doesn't have weekdays") + } + const endoptions = this.untilOrCount(until, count) + return `FREQ=WEEKLY;INTERVAL=${interval};BYDAY=${weekdays.join(',')}${endoptions}` + } + + monthly() { + const {interval, monthdate, weekdays, pos, until, count} = this.spec + const endoptions = this.untilOrCount(until, count) + if (pos === undefined) { + return `FREQ=MONTHLY;INTERVAL=${interval};BYMONTHDAY=${monthdate}${endoptions}` + } else if (monthdate === undefined && Array.isArray(weekdays)) { + return `FREQ=MONTHLY;INTERVAL=${interval};BYDAY=${weekdays.join( + ',' + )};BYSETPOS=${pos}${endoptions}` + } else { + throw new Error('Invalid monthly recurrence') + } + } + + yearly() { + const {interval, month, monthdate, weekdays, pos, until, count} = this.spec + const endoptions = this.untilOrCount(until, count) + if (pos === undefined) { + return `FREQ=YEARLY;INTERVAL=${interval};BYMONTH=${month};BYMONTHDAY=${monthdate}${endoptions}` + } else if (monthdate === undefined) { + return `FREQ=YEARLY;INTERVAL=${interval};BYDAY=${weekdays};BYMONTH=${month};BYSETPOS=${pos}${endoptions}` + } else { + throw new Error('Invalid yearly recurrence') + } + } +} diff --git a/ui/shared/calendar/react/RecurringEvents/RecurrenceEndPicker/RecurrenceEndPicker.stories.tsx b/ui/shared/calendar/react/RecurringEvents/RecurrenceEndPicker/RecurrenceEndPicker.stories.tsx new file mode 100644 index 00000000000..b0c2137e68f --- /dev/null +++ b/ui/shared/calendar/react/RecurringEvents/RecurrenceEndPicker/RecurrenceEndPicker.stories.tsx @@ -0,0 +1,106 @@ +// @ts-nocheck +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import React, {useCallback, useState} from 'react' +import moment from 'moment-timezone' +import {Story, Meta} from '@storybook/react' +import {Text} from '@instructure/ui-text' +import {View} from '@instructure/ui-view' +import RecurrenceEndPicker, {OnRecurrenceEndChangeType} from './RecurrenceEndPicker' + +export default { + title: 'Examples/Calendar/RecurringEvents/RecurrenceEndPicker', + component: RecurrenceEndPicker, +} as Meta + +const Template: Story = args => { + const {until, count} = args + const [currUntil, setCurrUntil] = useState(until) + const [currCount, setCurrCount] = useState(count) + + const handleChange = useCallback((newVal: OnRecurrenceEndChangeType): void => { + setCurrUntil(newVal.until) + setCurrCount(newVal.count) + }, []) + + return ( +
+ + + + + + +
+ {`until: ${moment.tz(currUntil, args.timezone).toISOString(true)}`} + {`count: ${currCount}`} +
+
+ ) +} + +const mytimezone = Intl.DateTimeFormat().resolvedOptions().timeZone + +export const Default = Template.bind({}) +Default.args = { + locale: 'en', + timezone: mytimezone, +} + +export const WithCount = Template.bind({}) +WithCount.args = { + locale: 'en', + timezone: mytimezone, + count: 2, +} + +export const WithUntil = Template.bind({}) +WithUntil.args = { + locale: 'en', + timezone: 'America/Los_Angeles', + until: '2024-06-30', +} + +export const InGerman = Template.bind({}) +InGerman.args = { + locale: 'de', + timezone: mytimezone, + until: '2024-06-30', +} + +export const CourseEnds = Template.bind({}) +CourseEnds.args = { + locale: 'en', + timezone: mytimezone, + courseEndAt: '2024-06-30', +} diff --git a/ui/shared/calendar/react/RecurringEvents/RecurrenceEndPicker/RecurrenceEndPicker.tsx b/ui/shared/calendar/react/RecurringEvents/RecurrenceEndPicker/RecurrenceEndPicker.tsx new file mode 100644 index 00000000000..95141493ad9 --- /dev/null +++ b/ui/shared/calendar/react/RecurringEvents/RecurrenceEndPicker/RecurrenceEndPicker.tsx @@ -0,0 +1,242 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import React, {useCallback, useEffect, useState} from 'react' +import moment from 'moment-timezone' +import CanvasDateInput from '@canvas/datetime/react/components/DateInput' +import {FormFieldGroup} from '@instructure/ui-form-field' +import {NumberInput} from '@instructure/ui-number-input' +// @ts-expect-error +import {px} from '@instructure/ui-utils' +// @ts-expect-error +import {RadioInput} from '@instructure/ui-radio-input' +import {ScreenReaderContent} from '@instructure/ui-a11y-content' +import {Text} from '@instructure/ui-text' +import {DEFAULT_COUNT, MAX_COUNT} from '../RRuleHelper' + +import {useScope} from '@canvas/i18n' + +const I18n = useScope('calendar_custom_recurring_event_end_picker') + +export type ModeValues = 'ON' | 'AFTER' +export type OnRecurrenceEndChangeType = { + until?: string + count?: number +} + +export type RecurrenceEndPickerProps = { + dtstart: string + locale: string + timezone: string + courseEndAt?: string + until?: string + count?: number + onChange: (state: OnRecurrenceEndChangeType) => void +} + +const makeDefaultCount = (count: number | undefined) => + typeof count === 'number' && count > 0 && count <= MAX_COUNT ? count : DEFAULT_COUNT + +export default function RecurrenceEndPicker({ + dtstart, + locale, + timezone, + courseEndAt, + until, + count, + onChange, +}: RecurrenceEndPickerProps) { + const [eventStart] = useState(() => { + return dtstart + ? moment.tz(dtstart, timezone).toISOString(true) + : moment().tz(timezone).toISOString(true) + }) + const [mode, setMode] = useState(() => { + if (until) return 'ON' + return 'AFTER' + }) + const [untilDate, setUntilDate] = useState(() => { + // to be consistent with what is returned from DateInput when the date + // is changed, initialize untilDate to be as the start of the day + if (until !== undefined) + return moment.tz(until, timezone).startOf('day').format('YYYY-MM-DDTHH:mm:ssZ') + const start = moment.tz(eventStart, timezone).startOf('day') + return start.add(1, 'year').format('YYYY-MM-DDTHH:mm:ssZ') + }) + const [countNumber, setCountNumber] = useState(makeDefaultCount(count)) + + const dateFormatter = new Intl.DateTimeFormat(locale, { + month: 'long', + day: 'numeric', + year: 'numeric', + timeZone: timezone, + }) + + useEffect(() => { + if (count === undefined && until === undefined) { + fireOnChange(mode, undefined, countNumber) + } + // on init, tell our parent if we cooked up a count + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (until) { + setMode('ON') + setUntilDate(until) + } else { + setMode('AFTER') + setCountNumber(makeDefaultCount(count)) + } + }, [count, until]) + + const formatCourseEndDate = (date?: string): string => { + if (!date) return '' + return dateFormatter.format(moment.tz(date, timezone).toDate()) + } + + const fireOnChange = useCallback( + (newMode, newUntil, newCount) => { + if (newMode === 'ON') { + if (newUntil === undefined) return + onChange({until: newUntil, count: undefined}) + } else if (newMode === 'AFTER') { + onChange({until: undefined, count: newCount}) + } else { + onChange({until: undefined, count: undefined}) + } + }, + [onChange] + ) + + const handleModeChange = useCallback( + (event: React.ChangeEvent): void => { + const newMode = event.target.value as ModeValues + setMode(newMode) + fireOnChange(newMode, untilDate, countNumber) + }, + [fireOnChange, untilDate, countNumber] + ) + + const handleCountChange = useCallback( + (_event: Event, value: string | number): void => { + const cnt = typeof value === 'string' ? parseInt(value, 10) : value + if (Number.isNaN(cnt)) return + if (cnt < 1) return + setCountNumber(cnt) + fireOnChange(mode, untilDate, cnt) + }, + [fireOnChange, mode, untilDate] + ) + + const handleDateChange = useCallback( + (date: Date | null) => { + if (!date) return + if (date.constructor.name !== 'Date') return + // js Date cannot parse ISO strings with milliseconds + const newISODate = moment + .tz(date, timezone) + .toISOString(true) + .replace(/\.\d+/, '') + .replace(/\+00:00/, 'Z') + setUntilDate(newISODate) + fireOnChange(mode, newISODate, countNumber) + }, + [timezone, fireOnChange, mode, countNumber] + ) + + const gridStyle = { + display: 'grid', + gridTemplateColumns: 'min-content min-content', + gridTemplateRows: 'auto auto', + rowGap: '0.5rem', + columnGap: '0.75rem', + justifyItems: 'start', + alignItems: 'center', + } + const alignMe = { + marginTop: '0.75rem', + alignSelf: 'start', + } + + return ( + +
+
+ +
+ {I18n.t('date')}} + selectedDate={untilDate} + formatDate={date => dateFormatter.format(date)} + onSelectedDateChange={handleDateChange} + messages={ + courseEndAt + ? [ + { + type: 'hint', + text: I18n.t('Course ends %{endat}', {endat: formatCourseEndDate(courseEndAt)}), + }, + ] + : undefined + } + /> +
+ +
+
+ {I18n.t('occurences')}} + value={countNumber} + width={`${px('3em') + px('4rem')}px`} + onChange={handleCountChange} + onIncrement={(event: Event) => { + if (countNumber === undefined || Number.isNaN(countNumber)) return + handleCountChange(event, countNumber + 1) + }} + onDecrement={(event: Event) => { + if (countNumber === undefined || Number.isNaN(countNumber)) return + handleCountChange(event, countNumber - 1) + }} + /> +
+ {I18n.t('occurrences')} +
+
+
+
+ ) +} diff --git a/ui/shared/calendar/react/RecurringEvents/RecurrenceEndPicker/__tests__/RecurrenceEndPicker.test.tsx b/ui/shared/calendar/react/RecurringEvents/RecurrenceEndPicker/__tests__/RecurrenceEndPicker.test.tsx new file mode 100644 index 00000000000..18997ba1776 --- /dev/null +++ b/ui/shared/calendar/react/RecurringEvents/RecurrenceEndPicker/__tests__/RecurrenceEndPicker.test.tsx @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import React from 'react' +import {render, act, fireEvent, screen, waitFor} from '@testing-library/react' +import moment from 'moment-timezone' +import {UnknownSubset} from '../../types' +import RecurrenceEndPicker, {RecurrenceEndPickerProps} from '../RecurrenceEndPicker' + +const defaultTZ = 'Asia/Tokyo' +const today = moment().tz(defaultTZ) + +export function formatDate(date: Date, locale: string, timezone: string) { + return new Intl.DateTimeFormat('en', { + month: 'long', + day: 'numeric', + year: 'numeric', + timeZone: timezone, + }).format(date) +} + +export function makeSimpleIsoDate(date: moment.Moment): string { + return date.format('YYYY-MM-DDTHH:mm:ssZ') +} + +export async function changeUntilDate( + enddate: moment.Moment, + newenddate: moment.Moment, + locale: string, + timezone: string +) { + const displayedUntil = formatDate(enddate.toDate(), locale, timezone) + const dateinput = screen.getByDisplayValue(displayedUntil) + const newEndDateStr = formatDate(newenddate.toDate(), locale, timezone) + act(() => { + fireEvent.change(dateinput, {target: {value: newEndDateStr}}) + }) + await waitFor(() => screen.getByDisplayValue(newEndDateStr)) + act(() => { + fireEvent.blur(dateinput) + }) +} + +const defaultProps = ( + overrides: UnknownSubset = {} +): RecurrenceEndPickerProps => ({ + locale: 'en', + timezone: defaultTZ, + dtstart: today.format('YYYY-MM-DDTHH:mm:ssZ'), + courseEndAt: undefined, + until: undefined, + count: undefined, + onChange: () => {}, + ...overrides, +}) + +describe('RecurrenceEndPicker', () => { + beforeEach(() => { + moment.tz.setDefault(defaultTZ) + }) + + it('renders', () => { + const props = {...defaultProps()} + const {getByDisplayValue, getByText, getAllByText} = render() + + expect(getAllByText('Ends:')).toHaveLength(2) + // the radio buttons + expect(getByText('on')).toBeInTheDocument() + expect(getByDisplayValue('ON')).toBeInTheDocument() + expect(getByText('after')).toBeInTheDocument() + expect(getByDisplayValue('AFTER')).toBeInTheDocument() + + expect(getByDisplayValue('5')).toBeInTheDocument() + const until = formatDate(today.clone().add(1, 'year').toDate(), props.locale, props.timezone) + expect(getByDisplayValue(until)).toBeInTheDocument() + }) + + it('fires onChange when the radio buttons are clicked', () => { + const onChange = jest.fn() + const enddate = today.clone().add(5, 'days').format('YYYY-MM-DD') + const {getByDisplayValue} = render( + + ) + + act(() => { + fireEvent.click(getByDisplayValue('AFTER')) + }) + + expect(onChange).toHaveBeenCalledWith({count: 5}) + }) + + it('fires onChange when the date input is changed', async () => { + const onChange = jest.fn() + const enddate = today.clone().add(5, 'days') + const props = {...defaultProps({onChange, until: makeSimpleIsoDate(enddate)})} + render() + + const newEndDate = enddate.clone().add(1, 'day') + await changeUntilDate(enddate, newEndDate, props.locale, props.timezone) + + expect(onChange).toHaveBeenCalledWith({ + until: newEndDate.startOf('day').format('YYYY-MM-DDTHH:mm:ssZ'), + count: undefined, + }) + }) + + it('fires onChange when the count input is changed', async () => { + const onChange = jest.fn() + const props = {...defaultProps({onChange, count: 5})} + const {getByDisplayValue} = render() + + const countinput = getByDisplayValue('5') + act(() => { + fireEvent.change(countinput, {target: {value: '6'}}) + }) + + expect(onChange).toHaveBeenCalledWith({ + until: undefined, + count: 6, + }) + }) +}) diff --git a/ui/shared/calendar/react/RecurringEvents/RepeatPicker/RepeatPicker.stories.tsx b/ui/shared/calendar/react/RecurringEvents/RepeatPicker/RepeatPicker.stories.tsx new file mode 100644 index 00000000000..f3535802cf7 --- /dev/null +++ b/ui/shared/calendar/react/RecurringEvents/RepeatPicker/RepeatPicker.stories.tsx @@ -0,0 +1,108 @@ +// @ts-nocheck +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import React, {useCallback, useState} from 'react' +import moment from 'moment-timezone' +import {Story, Meta} from '@storybook/react' +import {Text} from '@instructure/ui-text' +import {View} from '@instructure/ui-view' +import RepeatPicker, {OnRepeatPickerChangeType} from './RepeatPicker' +import {SelectedDaysArray} from '../WeekdayPicker/WeekdayPicker' + +export default { + title: 'Examples/Calendar/RecurringEvents/RepeatPicker', + component: RepeatPicker, +} as Meta + +const Template: Story = args => { + const [interval, setInterval] = useState(args.interval) + const [freq, setFreq] = useState(args.freq) + const [weekdays, setWeekdays] = useState(args.weekdays) + const [monthdate, setMonthdate] = useState(args.monthdate) + const [pos, setPos] = useState(null) + + const handleChange = useCallback((newVal: OnRepeatPickerChangeType): void => { + setInterval(newVal.interval) + setFreq(newVal.freq) + setWeekdays(newVal.weekdays) + setMonthdate(newVal.monthdate) + setPos(newVal.pos) + }, []) + + return ( +
+ + + + + + +
+ {`interval: ${interval}`} + {`freq: ${freq}`} + {`weekdays: ${weekdays}`} + {`monthdate: ${monthdate}`} + {`pos: ${pos}`} +
+
+ ) +} + +const TZ = Intl.DateTimeFormat().resolvedOptions().timeZone + +export const Default = Template.bind({}) +Default.args = { + locale: 'en', + timezone: TZ, + dtstart: moment().tz(TZ).format('YYYY-MM-DD'), + interval: 2, + freq: 'DAILY', +} + +export const ADifferentStart = Template.bind({}) +ADifferentStart.args = { + locale: 'en', + timezone: TZ, + dtstart: moment().tz(TZ).add(17, 'days').format('YYYY-MM-DD'), + interval: 2, + freq: 'MONTHLY', +} diff --git a/ui/shared/calendar/react/RecurringEvents/RepeatPicker/RepeatPicker.tsx b/ui/shared/calendar/react/RecurringEvents/RepeatPicker/RepeatPicker.tsx new file mode 100644 index 00000000000..129fb494f7e --- /dev/null +++ b/ui/shared/calendar/react/RecurringEvents/RepeatPicker/RepeatPicker.tsx @@ -0,0 +1,423 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import React, {useCallback, useEffect, useRef, useState} from 'react' +import moment from 'moment-timezone' +import WeekdayPicker from '../WeekdayPicker/WeekdayPicker' +import {useScope} from '@canvas/i18n' +import {ScreenReaderContent} from '@instructure/ui-a11y-content' +import {NumberInput} from '@instructure/ui-number-input' +// @ts-expect-error +import {px} from '@instructure/ui-utils' +import {SimpleSelect} from '@instructure/ui-simple-select' +import {Text} from '@instructure/ui-text' +import {View} from '@instructure/ui-view' + +import {AllRRULEDayValues, FrequencyValue, MonthlyModeValue, SelectedDaysArray} from '../types' + +const I18n = useScope('calendar_custom_recurring_event_repeat_picker') + +const {Option: SimpleSelectOption} = SimpleSelect as any + +export type RepeatPickerProps = { + locale: string + timezone: string + dtstart: string + interval: number + freq: FrequencyValue + weekdays?: SelectedDaysArray + pos?: number // 1: first, 2: second, ... + onChange: (state: OnRepeatPickerChangeType) => void +} + +export type OnRepeatPickerChangeType = { + interval: number + freq: FrequencyValue + weekdays?: SelectedDaysArray + monthdate?: number + month?: number + pos?: number +} + +type CardinalDayInMonth = { + cardinal: number + last: boolean +} + +export const weekdaysFromMoment = (m: moment.Moment): SelectedDaysArray => [ + AllRRULEDayValues[m.day()], +] +export const cardinalDayInMonth = (m: moment.Moment): CardinalDayInMonth => { + let last = false + const n = Math.ceil(m.date() / 7) + if (n >= 4 && m.clone().add(1, 'week').month() !== m.month()) { + last = true + } + return {cardinal: n, last} +} + +export const getWeekdayName = ( + datetime: moment.Moment, + locale: string, + timezone: string +): string => { + return new Intl.DateTimeFormat(locale, {weekday: 'long', timeZone: timezone}).format( + datetime.toDate() + ) +} + +export const getByMonthdateString = ( + datetime: moment.Moment, + locale: string, + timezone: string +): string => { + const cardinal = cardinalDayInMonth(datetime) + const dayname = getWeekdayName(datetime, locale, timezone) + const monthdateStrings = [ + I18n.t('on the first %{dayname}', {dayname}), + I18n.t('on the second %{dayname}', {dayname}), + I18n.t('on the third %{dayname}', {dayname}), + I18n.t('on the fourth %{dayname}', {dayname}), + I18n.t('on the fifth %{dayname}', {dayname}), + ] + return monthdateStrings[cardinal.cardinal - 1] +} + +export const isLastWeekdayInMonth = (m: moment.Moment): boolean => { + const n = Math.ceil(m.date() / 7) + return n >= 4 && m.clone().add(1, 'week').month() !== m.month() +} + +export const getLastWeekdayInMonthString = (dayname: string): string => { + return I18n.t('on the last %{dayname}', {dayname}) +} + +function getSelectTextWidth(strings: string[]) { + const testdiv = document.createElement('div') + testdiv.setAttribute('style', 'position: absolute; left: -9999px; visibility: hidden;') + testdiv.innerHTML = `
${strings.join('
')}
` + document.body.appendChild(testdiv) + const w = `${testdiv.getBoundingClientRect().width + 24 + 12 + 14 + 2}px` + testdiv.remove() + return w +} + +function getMonthlyMode( + freq: FrequencyValue, + weekdays?: SelectedDaysArray, + pos?: number +): MonthlyModeValue { + if (freq === 'MONTHLY' && Array.isArray(weekdays) && typeof pos === 'number') { + if (pos >= 0) { + return 'BYMONTHDAY' + } else { + return 'BYLASTMONTHDAY' + } + } + return 'BYMONTHDATE' +} + +export default function RepeatPicker({ + locale, + timezone, + dtstart, + interval, + freq, + weekdays, + pos, + onChange, +}: RepeatPickerProps) { + const [eventStart, setEventStart] = useState(moment(dtstart).tz(timezone)) + + const [currInterval, setCurrInterval] = useState(interval) + const [currFreq, setCurrFreq] = useState(freq) + const [currWeekDays, setCurrWeekdays] = useState( + weekdays ?? weekdaysFromMoment(eventStart) + ) + const [currPos, setCurrPos] = useState(pos) + const [currMonthlyMode, setCurrMonthlyMode] = useState(() => { + return getMonthlyMode(freq, weekdays, pos) + }) + + // I cannot get flexbox to make the monthly options select wide enough + // so the value is not clipped. Let's calculate the space needed for + // the max-width value string + all SimpleSelect's padding and such + // (plus 2px, because it was needed), and use that to set SimpleSelect's + // text input width + const [monthlyOptionsWidth] = useState(() => { + const bydate = I18n.t('on day %{date}', {date: eventStart.date()}) + const byday = getByMonthdateString(eventStart, locale, timezone) + return getSelectTextWidth([bydate, byday]) + }) + // ditto the freq picker + const [freqPickerWidth] = useState(() => { + const d = I18n.t('Days') + const m = I18n.t('MOnths') + const w = I18n.t('Weeks') + const y = I18n.t('Years') + return getSelectTextWidth([d, m, w, y]) + }) + const activeElement = useRef(null) + const freqRef = useRef(null) + + useEffect(() => { + setEventStart(moment.tz(dtstart, timezone)) + }, [dtstart, timezone]) + + useEffect(() => { + setCurrInterval(interval) + setCurrFreq(freq) + setCurrWeekdays(weekdays ?? weekdaysFromMoment(eventStart)) + setCurrPos(pos) + setCurrMonthlyMode(() => { + return getMonthlyMode(freq, weekdays, pos) + }) + }, [freq, interval, weekdays, pos, eventStart]) + + useEffect(() => { + if (activeElement.current != null && freqRef.current !== null) { + activeElement.current = null + freqRef.current.focus() + } + }, [freq]) + + const fireOnChange = useCallback( + (i, f, w, md, m, p) => { + if (f === 'YEARLY') { + onChange({ + interval: i, + freq: f, + weekdays: undefined, + monthdate: md, + month: m, + pos: undefined, + }) + } else if (f === 'MONTHLY') { + onChange({interval: i, freq: f, weekdays: w, monthdate: md, month: m, pos: p}) + } else if (f === 'WEEKLY') { + onChange({ + interval: i, + freq: f, + weekdays: w, + monthdate: undefined, + month: undefined, + pos: undefined, + }) + } else if (f === 'DAILY') { + onChange({ + interval: i, + freq: f, + weekdays: undefined, + monthdate: undefined, + month: undefined, + pos: undefined, + }) + } + }, + [onChange] + ) + + const handleChangeMonthlyMode = useCallback( + (_event: Event, {value}) => { + const newMonthlyMode = value as MonthlyModeValue + + setCurrMonthlyMode(newMonthlyMode) + if (newMonthlyMode === 'BYMONTHDAY') { + const eventWeekday = AllRRULEDayValues[eventStart.day()] + setCurrWeekdays([eventWeekday]) + const newPos = cardinalDayInMonth(eventStart).cardinal + setCurrPos(newPos) + fireOnChange(currInterval, 'MONTHLY', [eventWeekday], undefined, undefined, newPos) + } else if (newMonthlyMode === 'BYMONTHDATE') { + fireOnChange(currInterval, 'MONTHLY', undefined, eventStart.date(), undefined, undefined) + } else { + // bypos, only get here if it's "last" + fireOnChange(currInterval, 'MONTHLY', currWeekDays, undefined, undefined, -1) + } + }, + // it wants eventStart, which we replace with dtstart and timezone + // eslint-disable-next-line react-hooks/exhaustive-deps + [dtstart, timezone, fireOnChange, currInterval, currFreq, currPos] + ) + + const handleIntervalChange = useCallback( + (_event: Event, value: string | number) => { + const num = typeof value === 'string' ? parseInt(value, 10) : value + if (Number.isNaN(num)) return + if (num < 1) return + setCurrInterval(num) + let monthdate, month + if (currFreq === 'YEARLY') { + monthdate = eventStart.date() + month = eventStart.month() + 1 + } + + fireOnChange(num, currFreq, currWeekDays, monthdate, month, currPos) + }, + [currFreq, fireOnChange, currWeekDays, currPos, eventStart] + ) + + const handleFreqChange = useCallback( + (_event: Event, {value}) => { + activeElement.current = document.activeElement as HTMLElement + + setCurrFreq(value) + if (value === 'YEARLY') { + const monthdate = eventStart.date() + const month = eventStart.month() + 1 + fireOnChange(currInterval, value, undefined, monthdate, month, undefined) + } else if (value === 'MONTHLY') { + handleChangeMonthlyMode(_event, {value: currMonthlyMode}) + } else { + fireOnChange(currInterval, value, currWeekDays, undefined, undefined, undefined) + } + }, + [eventStart, fireOnChange, currInterval, handleChangeMonthlyMode, currMonthlyMode, currWeekDays] + ) + + const handleWeekdayChange = useCallback( + (newSelectedDays: SelectedDaysArray) => { + setCurrWeekdays(newSelectedDays) + if (currFreq !== 'WEEKLY') return + fireOnChange(currInterval, currFreq, newSelectedDays, undefined, undefined, undefined) + }, + [fireOnChange, currInterval, currFreq] + ) + + const yearlyFreqToText = useCallback(() => { + return I18n.t('on %{date}', { + date: new Intl.DateTimeFormat(locale, { + month: 'long', + day: 'numeric', + timeZone: timezone, + }).format(eventStart.toDate()), + }) + }, [eventStart, locale, timezone]) + + return ( +
+
+ + + {I18n.t('Repeat every:')} + + +
+ + {I18n.t('every')}} + value={interval} + width={`${px('1em') + px('4rem')}px`} + onChange={handleIntervalChange} + onIncrement={event => { + handleIntervalChange(event, interval + 1) + }} + onDecrement={event => { + handleIntervalChange(event, interval - 1) + }} + /> + + + { + freqRef.current = node + }} + key={`${interval}-${freq}`} + renderLabel={{I18n.t('frequency')}} + assistiveText={I18n.t('Use arrow keys to navigate options.')} + value={freq} + width={freqPickerWidth} + onChange={handleFreqChange} + > + + {I18n.t('single_and_plural_days', {one: 'Day', other: 'Days'}, {count: interval})} + + + {I18n.t( + 'single_and_plural_weeks', + {one: 'Week', other: 'Weeks'}, + {count: interval} + )} + + + {I18n.t( + 'single_and_plural_months', + {one: 'Month', other: 'Months'}, + {count: interval} + )} + + + {I18n.t( + 'single_and_plural_years', + {one: 'Year', other: 'Years'}, + {count: interval} + )} + + + + {freq === 'MONTHLY' && ( +
+ {I18n.t('day of month')}} + assistiveText={I18n.t('Use arrow keys to navigate options.')} + value={currMonthlyMode} + width={monthlyOptionsWidth} + onChange={handleChangeMonthlyMode} + > + + {I18n.t('on day %{date}', {date: eventStart.date()})} + + + {getByMonthdateString(eventStart, locale, timezone)} + + {isLastWeekdayInMonth(eventStart) && ( + + {getLastWeekdayInMonthString(getWeekdayName(eventStart, locale, timezone))} + + )} + +
+ )} + {freq === 'YEARLY' && {yearlyFreqToText()}} +
+
+ {freq === 'WEEKLY' && ( + + + + )} +
+ ) +} diff --git a/ui/shared/calendar/react/RecurringEvents/RepeatPicker/__tests__/RepeatPicker.test.tsx b/ui/shared/calendar/react/RecurringEvents/RepeatPicker/__tests__/RepeatPicker.test.tsx new file mode 100644 index 00000000000..5a227790ea9 --- /dev/null +++ b/ui/shared/calendar/react/RecurringEvents/RepeatPicker/__tests__/RepeatPicker.test.tsx @@ -0,0 +1,268 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import React from 'react' +import {render, act, fireEvent, screen} from '@testing-library/react' +import moment from 'moment-timezone' +import RepeatPicker, { + RepeatPickerProps, + cardinalDayInMonth, + getByMonthdateString, + getLastWeekdayInMonthString, + getWeekdayName, + isLastWeekdayInMonth, + weekdaysFromMoment, +} from '../RepeatPicker' +import {UnknownSubset} from '../../types' + +export function changeFreq(from: string, to: string): void { + const freq = screen.getByDisplayValue(from) + act(() => { + fireEvent.click(freq) + }) + const opt = screen.getByText(to) + act(() => { + fireEvent.click(opt) + }) +} + +const defaultTZ = 'Asia/Tokyo' +const today = moment().tz(defaultTZ) + +const defaultProps = (overrides: UnknownSubset = {}): RepeatPickerProps => ({ + locale: 'en', + timezone: defaultTZ, + dtstart: today.toISOString(true).replace(/\.\d+Z/, ''), + interval: 1, + freq: 'DAILY', + weekdays: undefined, + pos: undefined, + onChange: () => {}, + ...overrides, +}) + +describe('RepeatPicker', () => { + beforeEach(() => { + moment.tz.setDefault(defaultTZ) + }) + + describe('utilities', () => { + it('cardinalDayInMonth returns the correct day', () => { + expect(cardinalDayInMonth(moment('2023-06-02'))).toEqual({cardinal: 1, last: false}) + expect(cardinalDayInMonth(moment('2023-06-09'))).toEqual({cardinal: 2, last: false}) + expect(cardinalDayInMonth(moment('2023-06-16'))).toEqual({cardinal: 3, last: false}) + expect(cardinalDayInMonth(moment('2023-06-23'))).toEqual({cardinal: 4, last: false}) + expect(cardinalDayInMonth(moment('2023-06-30'))).toEqual({cardinal: 5, last: true}) + expect(cardinalDayInMonth(moment('2023-07-25'))).toEqual({cardinal: 4, last: true}) + }) + + it('getWeekdayName returns the dates day of the week', () => { + expect(getWeekdayName(moment('2023-06-05'), 'en', defaultTZ)).toEqual('Monday') + expect(getWeekdayName(moment('2023-06-06'), 'en', defaultTZ)).toEqual('Tuesday') + expect(getWeekdayName(moment('2023-06-07'), 'en', defaultTZ)).toEqual('Wednesday') + expect(getWeekdayName(moment('2023-06-08'), 'en', defaultTZ)).toEqual('Thursday') + expect(getWeekdayName(moment('2023-06-09'), 'en', defaultTZ)).toEqual('Friday') + expect(getWeekdayName(moment('2023-06-10'), 'en', defaultTZ)).toEqual('Saturday') + expect(getWeekdayName(moment('2023-06-11'), 'en', defaultTZ)).toEqual('Sunday') + }) + + it('getByMonthdateString returns the correct string', () => { + expect(getByMonthdateString(moment('2023-07-03'), 'en', defaultTZ)).toEqual( + 'on the first Monday' + ) + expect(getByMonthdateString(moment('2023-07-10'), 'en', defaultTZ)).toEqual( + 'on the second Monday' + ) + expect(getByMonthdateString(moment('2023-07-17'), 'en', defaultTZ)).toEqual( + 'on the third Monday' + ) + expect(getByMonthdateString(moment('2023-07-24'), 'en', defaultTZ)).toEqual( + 'on the fourth Monday' + ) + expect(getByMonthdateString(moment('2023-07-31'), 'en', defaultTZ)).toEqual( + 'on the fifth Monday' + ) + }) + + it('isLastWeekdayInMonth returns the correct boolean', () => { + expect(isLastWeekdayInMonth(moment('2023-06-29'))).toEqual(true) + expect(isLastWeekdayInMonth(moment('2023-06-22'))).toEqual(false) + }) + + it('getLastWeekdayInMonthString returns the formatted string', () => { + expect(getLastWeekdayInMonthString('Fizzday')).toEqual('on the last Fizzday') + }) + + it('weekdaysFromMoment returns the correct weekdays', () => { + expect(weekdaysFromMoment(moment('2023-06-05'))).toEqual(['MO']) + expect(weekdaysFromMoment(moment('2023-06-06'))).toEqual(['TU']) + expect(weekdaysFromMoment(moment('2023-06-07'))).toEqual(['WE']) + expect(weekdaysFromMoment(moment('2023-06-08'))).toEqual(['TH']) + expect(weekdaysFromMoment(moment('2023-06-09'))).toEqual(['FR']) + expect(weekdaysFromMoment(moment('2023-06-10'))).toEqual(['SA']) + expect(weekdaysFromMoment(moment('2023-06-11'))).toEqual(['SU']) + }) + }) + + describe('component', () => { + it('renders daily', () => { + const {getByDisplayValue, getByTestId, getByText} = render( + + ) + expect(getByText('Repeat every:')).toBeInTheDocument() + expect(getByTestId('repeat-interval')).toBeInTheDocument() + expect(getByTestId('repeat-frequency')).toBeInTheDocument() + expect(getByDisplayValue('1')).toBeInTheDocument() + expect(getByDisplayValue('Day')).toBeInTheDocument() + }) + + it('renders plural daily', () => { + const {getByDisplayValue} = render() + expect(getByDisplayValue('2')).toBeInTheDocument() + expect(getByDisplayValue('Days')).toBeInTheDocument() + }) + + it('renders weekly', () => { + const {getByDisplayValue} = render() + expect(getByDisplayValue('1')).toBeInTheDocument() + expect(getByDisplayValue('Week')).toBeInTheDocument() + }) + + it('renders plural weeks', () => { + const {getByDisplayValue} = render( + + ) + expect(getByDisplayValue('2')).toBeInTheDocument() + expect(getByDisplayValue('Weeks')).toBeInTheDocument() + }) + + // we can assume months and years do plural correctly too + + it('renders monthly by date', () => { + const {getByDisplayValue} = render() + expect(getByDisplayValue('1')).toBeInTheDocument() + expect(getByDisplayValue('Month')).toBeInTheDocument() + expect(getByDisplayValue(`on day ${today.date()}`)).toBeInTheDocument() + }) + + it('renders monthly by day', () => { + const today_day = weekdaysFromMoment(today) + const pos = cardinalDayInMonth(today).cardinal + + const {getByDisplayValue} = render( + + ) + expect(getByDisplayValue('1')).toBeInTheDocument() + expect(getByDisplayValue('Month')).toBeInTheDocument() + const which_day = getByMonthdateString(today, 'en', defaultTZ) + expect(getByDisplayValue(which_day)).toBeInTheDocument() + }) + + it('renders monthly by the last day', () => { + const {getByDisplayValue} = render( + + ) + expect(getByDisplayValue('1')).toBeInTheDocument() + expect(getByDisplayValue('Month')).toBeInTheDocument() + expect(getByDisplayValue('on the last Friday')).toBeInTheDocument() + }) + + it('renders monthly correctly when the last day is in the 4th week', () => { + const {getByTestId, getByRole} = render( + + ) + fireEvent.click(getByTestId('repeat-month-mode')) + expect(getByRole('option', {name: 'on day 25'})).toBeInTheDocument() + expect(getByRole('option', {name: 'on the fourth Tuesday'})).toBeInTheDocument() + expect(getByRole('option', {name: 'on the last Tuesday'})).toBeInTheDocument() + }) + + it('renders yearly by date', () => { + const {getByDisplayValue, getByText} = render( + + ) + expect(getByDisplayValue('1')).toBeInTheDocument() + expect(getByDisplayValue('Year')).toBeInTheDocument() + expect(getByText(`on ${today.format('MMMM D')}`)).toBeInTheDocument() + }) + + it('calls onChange when interval changes', () => { + const onChange = jest.fn() + const {getByDisplayValue} = render() + const interval = getByDisplayValue('1') + act(() => { + const event = {target: {value: '2'}} + fireEvent.change(interval, event) + }) + expect(onChange).toHaveBeenCalledWith({ + interval: 2, + freq: 'DAILY', + weekdays: undefined, + monthdate: undefined, + month: undefined, + pos: undefined, + }) + }) + + it('calls onChange when freq changes', async () => { + const onChange = jest.fn() + render() + changeFreq('Day', 'Week') + expect(onChange).toHaveBeenCalledWith({ + interval: 1, + freq: 'WEEKLY', + weekdays: weekdaysFromMoment(today), + monthdate: undefined, + month: undefined, + pos: undefined, + }) + }) + + it('calls onChange when weekdays changes', async () => { + const onChange = jest.fn() + const {getByDisplayValue} = render( + + ) + // get 2 weekdays in the correct order + const weekdays = weekdaysFromMoment(today) + let notTodayWeekday + if (weekdays[0] === 'SU') { + weekdays.push('MO') + notTodayWeekday = 'MO' + } else { + weekdays.unshift('SU') + notTodayWeekday = 'SU' + } + + const weekday = getByDisplayValue(notTodayWeekday) + act(() => { + fireEvent.click(weekday) + }) + expect(onChange).toHaveBeenCalledWith({ + interval: 1, + freq: 'WEEKLY', + weekdays, + monthdate: undefined, + month: undefined, + pos: undefined, + }) + }) + }) +}) diff --git a/ui/shared/calendar/react/WeekdayPicker/WeekdayPicker.stories.tsx b/ui/shared/calendar/react/RecurringEvents/WeekdayPicker/WeekdayPicker.stories.tsx similarity index 97% rename from ui/shared/calendar/react/WeekdayPicker/WeekdayPicker.stories.tsx rename to ui/shared/calendar/react/RecurringEvents/WeekdayPicker/WeekdayPicker.stories.tsx index 97c04f2f1ff..cc078b40a95 100644 --- a/ui/shared/calendar/react/WeekdayPicker/WeekdayPicker.stories.tsx +++ b/ui/shared/calendar/react/RecurringEvents/WeekdayPicker/WeekdayPicker.stories.tsx @@ -30,7 +30,7 @@ I18n.translations['zh-Hant'] = { } export default { - title: 'Examples/Calendar/WeekdayPicker', + title: 'Examples/Calendar/RecurringEvents/WeekdayPicker', component: WeekdayPicker, } as Meta diff --git a/ui/shared/calendar/react/WeekdayPicker/WeekdayPicker.tsx b/ui/shared/calendar/react/RecurringEvents/WeekdayPicker/WeekdayPicker.tsx similarity index 95% rename from ui/shared/calendar/react/WeekdayPicker/WeekdayPicker.tsx rename to ui/shared/calendar/react/RecurringEvents/WeekdayPicker/WeekdayPicker.tsx index 0a11a6d1749..e95b9c4d0c3 100644 --- a/ui/shared/calendar/react/WeekdayPicker/WeekdayPicker.tsx +++ b/ui/shared/calendar/react/RecurringEvents/WeekdayPicker/WeekdayPicker.tsx @@ -33,7 +33,9 @@ import {Text} from '@instructure/ui-text' import {View} from '@instructure/ui-view' import {useScope} from '@canvas/i18n' -const I18n = useScope('calendar_weekday_picker') +import {RRULEDayValue, SelectedDaysArray} from '../types' + +const I18n = useScope('calendar_custom_recurring_event_weekday_picker') export type WeekArray = [string, string, string, string, string, string, string] export type WeekDaysSpec = { @@ -42,9 +44,6 @@ export type WeekDaysSpec = { dayRRULEValues: WeekArray } -export type RRULEDayValue = 'SU' | 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' -export type SelectedDaysArray = RRULEDayValue[] - export type OnDaysChange = (selectedDays: SelectedDaysArray) => void export type WeekdayPickerProps = { @@ -54,8 +53,6 @@ export type WeekdayPickerProps = { } const defaultWeekDayAbbreviations = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'] -// useful for testing, though it is used in storybook -// ['星期日', '週一', '週二', '週三', '週四', '週五', '星期六'] export default function WeekdayPicker({locale, selectedDays = [], onChange}: WeekdayPickerProps) { const [weekDays, setWeekDays] = useState({ diff --git a/ui/shared/calendar/react/WeekdayPicker/__tests__/WeekdayPicker.test.tsx b/ui/shared/calendar/react/RecurringEvents/WeekdayPicker/__tests__/WeekdayPicker.test.tsx similarity index 100% rename from ui/shared/calendar/react/WeekdayPicker/__tests__/WeekdayPicker.test.tsx rename to ui/shared/calendar/react/RecurringEvents/WeekdayPicker/__tests__/WeekdayPicker.test.tsx diff --git a/ui/shared/calendar/react/RecurringEvents/__tests__/RRuleHelper.test.ts b/ui/shared/calendar/react/RecurringEvents/__tests__/RRuleHelper.test.ts new file mode 100644 index 00000000000..00d4097b93a --- /dev/null +++ b/ui/shared/calendar/react/RecurringEvents/__tests__/RRuleHelper.test.ts @@ -0,0 +1,218 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +import RRuleHelper, {RRuleHelperSpec, ISODateToIcalDate, icalDateToISODate} from '../RRuleHelper' +import moment from 'moment-timezone' + +const defaultTZ = 'Asia/Tokyo' + +describe('RRuleHelper', () => { + beforeAll(() => { + moment.tz.setDefault(defaultTZ) + }) + + describe('RRuleHelper.parseString', () => { + it('handles an empty string', () => { + const spec = RRuleHelper.parseString('') + expect(spec.freq).toEqual('DAILY') + expect(spec.interval).toEqual(1) + expect(spec.count).toEqual(5) + + expect(spec.weekdays).toBeUndefined() + expect(spec.month).toBeUndefined() + expect(spec.monthdate).toBeUndefined() + expect(spec.pos).toBeUndefined() + expect(spec.until).toBeUndefined() + }) + + it('parses a daily rule', () => { + const spec = RRuleHelper.parseString('FREQ=DAILY;INTERVAL=1;COUNT=10') + expect(spec.freq).toEqual('DAILY') + expect(spec.interval).toEqual(1) + expect(spec.count).toEqual(10) + + expect(spec.weekdays).toBeUndefined() + expect(spec.month).toBeUndefined() + expect(spec.monthdate).toBeUndefined() + expect(spec.pos).toBeUndefined() + expect(spec.until).toBeUndefined() + }) + + it('parses a weekly rule', () => { + const spec = RRuleHelper.parseString( + 'FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR;UNTIL=20201231T000000Z' + ) + expect(spec.freq).toEqual('WEEKLY') + expect(spec.interval).toEqual(1) + expect(spec.weekdays).toEqual(['MO', 'WE', 'FR']) + expect(spec.until).toEqual('2020-12-31T00:00:00Z') + + expect(spec.month).toBeUndefined() + expect(spec.monthdate).toBeUndefined() + expect(spec.pos).toBeUndefined() + expect(spec.count).toBeUndefined() + }) + + it('parses a monthly by date rule', () => { + const spec = RRuleHelper.parseString( + 'FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=17;BYMONTH=12;UNTIL=20201231T000000Z' + ) + expect(spec.freq).toEqual('MONTHLY') + expect(spec.interval).toEqual(1) + expect(spec.monthdate).toEqual(17) + expect(spec.month).toEqual(12) + expect(spec.until).toEqual('2020-12-31T00:00:00Z') + + expect(spec.weekdays).toBeUndefined() + expect(spec.pos).toBeUndefined() + expect(spec.count).toBeUndefined() + }) + + it('parses a monthly bypos rule', () => { + const spec = RRuleHelper.parseString('FREQ=MONTHLY;INTERVAL=2;BYDAY=MO;BYSETPOS=1;COUNT=7') + expect(spec.freq).toEqual('MONTHLY') + expect(spec.interval).toEqual(2) + expect(spec.weekdays).toEqual(['MO']) + expect(spec.pos).toEqual(1) + expect(spec.count).toEqual(7) + + expect(spec.month).toBeUndefined() + expect(spec.monthdate).toBeUndefined() + expect(spec.until).toBeUndefined() + }) + + it('parses a yearly rule', () => { + const spec = RRuleHelper.parseString('FREQ=YEARLY;INTERVAL=1;BYMONTH=9;BYMONTHDAY=17') + expect(spec.freq).toEqual('YEARLY') + expect(spec.interval).toEqual(1) + expect(spec.monthdate).toEqual(17) + expect(spec.month).toEqual(9) + expect(spec.count).toBeUndefined() + expect(spec.pos).toBeUndefined() + expect(spec.until).toBeUndefined() + }) + }) + + describe('RRuleHelper constructor', () => { + it('creates a daily rule', () => { + const rrh = new RRuleHelper({freq: 'DAILY', interval: 1, count: 10}) + expect(rrh.toString()).toEqual('FREQ=DAILY;INTERVAL=1;COUNT=10') + }) + + it('creates a weekly rule', () => { + const rrh = new RRuleHelper({ + freq: 'WEEKLY', + interval: 2, + weekdays: ['MO', 'WE', 'FR'], + until: '2020-12-31T00:00:00Z', + }) + expect(rrh.toString()).toEqual('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;UNTIL=20201231T000000Z') + }) + + it('creates a monthly by date rule', () => { + const rrh = new RRuleHelper({ + freq: 'MONTHLY', + interval: 1, + monthdate: 17, + month: 12, + until: '2020-12-31T00:00:00Z', + }) + expect(rrh.toString()).toEqual('FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=17;UNTIL=20201231T000000Z') + }) + + it('creates a monthly bypos rule', () => { + const spec: RRuleHelperSpec = { + freq: 'MONTHLY', + interval: 1, + weekdays: ['MO', 'TU'], + pos: 2, + count: 7, + } + const rrh = new RRuleHelper(spec) + expect(rrh.toString()).toEqual('FREQ=MONTHLY;INTERVAL=1;BYDAY=MO,TU;BYSETPOS=2;COUNT=7') + }) + + it('creates a yearly rule', () => { + const spec: RRuleHelperSpec = { + freq: 'YEARLY', + interval: 1, + monthdate: 17, + month: 9, + count: 5, + } + const rrh = new RRuleHelper(spec) + expect(rrh.toString()).toEqual('FREQ=YEARLY;INTERVAL=1;BYMONTH=9;BYMONTHDAY=17;COUNT=5') + }) + + it('throws an exception convering an invalid spec to an RRULE', () => { + const rrh = new RRuleHelper({ + freq: 'WEEKLY', + interval: 2, + weekdays: [], + until: '2020-12-31T00:00:00Z', + }) + expect(() => rrh.toString()).toThrow() + }) + + describe('isValid', () => { + it('returns true for valid specs', () => { + const spec: RRuleHelperSpec = { + freq: 'YEARLY', + interval: 1, + monthdate: 17, + month: 9, + count: 5, + } + const rrh = new RRuleHelper(spec) + expect(rrh.isValid()).toEqual(true) + }) + + it('returns false for invalid specs', () => { + const spec: RRuleHelperSpec = { + freq: 'WEEKLY', + interval: 1, + weekdays: [], + pos: 1, + count: 7, + } + const rrh = new RRuleHelper(spec) + expect(rrh.isValid()).toEqual(false) + }) + }) + }) + + describe('ISODateToIcalDate', () => { + it('converts an ISO date to an ical date', () => { + expect(ISODateToIcalDate('2020-12-31T00:00:00Z')).toEqual('20201231T000000Z') + }) + + it('takes timezone into account', () => { + expect(ISODateToIcalDate('2020-12-31T00:00:00-05:00')).toEqual('20201231T050000Z') + }) + }) + + describe('icalDateToISODate', () => { + it('converts an ical date to an ISO date', () => { + expect(icalDateToISODate('20201231T000000Z')).toEqual('2020-12-31T00:00:00Z') + }) + + it('takes timezone into account', () => { + expect(icalDateToISODate('20201231T000000-05:00')).toEqual('2020-12-31T05:00:00Z') + }) + }) +}) diff --git a/ui/shared/calendar/react/RecurringEvents/types.ts b/ui/shared/calendar/react/RecurringEvents/types.ts new file mode 100644 index 00000000000..0487a745906 --- /dev/null +++ b/ui/shared/calendar/react/RecurringEvents/types.ts @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This file is part of Canvas. + * + * Canvas is free software: you can redistribute it and/or modify it under + * the terms of the GNU Affero General Public License as published by the Free + * Software Foundation, version 3 of the License. + * + * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. + * + * You should have received a copy of the GNU Affero General Public License along + * with this program. If not, see . + */ + +export type UnknownSubset = { + [K in keyof T]?: T[K] +} + +export type FrequencyValue = 'YEARLY' | 'MONTHLY' | 'WEEKLY' | 'DAILY' +export const FrequencyOptionStrings: FrequencyValue[] = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY'] + +export type MonthlyModeValue = 'BYMONTHDATE' | 'BYMONTHDAY' | 'BYLASTMONTHDAY' + +export type RRULEDayValue = 'SU' | 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' +export type SelectedDaysArray = RRULEDayValue[] +export const AllRRULEDayValues: SelectedDaysArray = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] diff --git a/ui/shared/instui-bindings/react/Modal.tsx b/ui/shared/instui-bindings/react/Modal.tsx index a6e53062409..4c82933e5a4 100644 --- a/ui/shared/instui-bindings/react/Modal.tsx +++ b/ui/shared/instui-bindings/react/Modal.tsx @@ -71,7 +71,7 @@ function CanvasModal({ {title} - +