add all the custom recurrence UI

closes LF-356
flag=none

test plan:
  - yarn storybook
  - play with all the stuff under Calendar > RecurringEvents
  - especially play with CustomRecurrenceModal, since that's
    what will go into Canvas
  > expect it to spit out correct RRULEs

  - in CustomRecurrenceModal story
  - change repeat every to Month
  - open the Select
  > expect "Monthly on day 7" and "Monthly on the first Monday"
    as the 2 options
  - in the form at the bottom of the page, change the eventStart
    string so the day of the week is the last one of the month
  > expect the Select to have 3 options for the date,
    the nth day of the week, and the last day of the week

Change-Id: Id68645399880c97d86a67fd974d06eabf58f674c
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/321191
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Juan Chavez <juan.chavez@instructure.com>
Reviewed-by: Robin Kuss <rkuss@instructure.com>
QA-Review: Juan Chavez <juan.chavez@instructure.com>
QA-Review: Robin Kuss <rkuss@instructure.com>
Product-Review: Ed Schiebel <eschiebel@instructure.com>
This commit is contained in:
Ed Schiebel 2023-06-22 17:51:18 -04:00
parent ea018d3547
commit 9bcea1f53c
24 changed files with 2680 additions and 15 deletions

53
Courses Normal file
View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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",

View File

@ -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"
}
}
)

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<CustomRecurrenceProps> = args => {
const {RRULE = '', eventStart, timezone, locale} = args
const [currRRULESpec, setCurrRRULESpec] = useState<RRuleHelperSpec>(
new RRuleHelper(RRuleHelper.parseString(RRULE)).spec
)
const [currEventStart, setCurrEventStart] = useState<string>(() => {
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 (
<div style={{maxWidth: '700px'}}>
<style>
button:focus {'{'} outline: 2px solid dodgerblue; {'}'}
</style>
<button type="button" onClick={e => e.target.focus()}>
tab stop before
</button>
<View as="div" margin="small" width="50%">
<CustomRecurrence
eventStart={currEventStart}
locale={locale}
timezone={timezone}
rruleSpec={currRRULESpec}
onChange={handleChange}
/>
</View>
<button type="button" onClick={e => e.target.focus()}>
tab stop after
</button>
<div style={{margin: '.75rem 0', lineHeight: 1.5}}>
<Text as="div">eventStart: {currEventStart}</Text>
<Text as="div">result: {specToRule(currRRULESpec)}</Text>
</div>
</div>
)
}
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: '',
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<RRuleHelperSpec>
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<FrequencyValue>(rrule_obj.spec.freq)
const [interval, setInterval] = useState<number>(rrule_obj.spec.interval)
const [weekdays, setWeekdays] = useState<RRULEDayValue[] | undefined>(rrule_obj.spec.weekdays)
const [month, setMonth] = useState<number | undefined>(rrule_obj.spec.month)
const [monthdate, setMonthdate] = useState<number | undefined>(rrule_obj.spec.monthdate)
const [pos, setPos] = useState<number | undefined>(rrule_obj.spec.pos)
const [count, setCount] = useState<number | undefined>(rrule_obj.spec.count)
const [until, setUntil] = useState<string | undefined>(rrule_obj.spec.until)
const [dtstart_str, setDtstartStr] = useState<string>(startToString(eventStart, timezone))
const stateToSpec = useCallback<StateToSpecFunc>(
(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 (
<View as="div" data-testid="custom-recurrence">
<View as="div" margin="small 0">
<RepeatPicker
locale={locale}
timezone={timezone}
dtstart={dtstart_str}
interval={interval}
freq={freq}
weekdays={weekdays}
pos={pos}
onChange={handleFrequencyChange}
/>
</View>
<View as="div" margin="small 0">
<RecurrenceEndPicker
dtstart={dtstart_str}
locale={locale}
timezone={timezone}
until={until}
count={count}
courseEndAt={courseEndAt}
onChange={handleEndChange}
/>
</View>
</View>
)
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<CustomRecurrenceProps> = {},
specOverrides: UnknownSubset<RRuleHelperSpec> = {}
): 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(<CustomRecurrence {...props} />)
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(<CustomRecurrence {...props} />)
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(<CustomRecurrence {...props} />)
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(<CustomRecurrence {...props} />)
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(<CustomRecurrence {...props} />)
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(<CustomRecurrence {...props} />)
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'),
})
})
})

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<CustomRecurrenceModalProps> = args => {
const {RRULE = ''} = args
const [currRRULE, setCurrRRULE] = useState<string>(RRULE)
const [isModalOpen, setIsModalOpen] = useState<boolean>(true)
const handleChange = useCallback((newRRULE: string | null) => {
setCurrRRULE(newRRULE)
setIsModalOpen(false)
}, [])
return (
<div style={{maxWidth: '700px'}}>
<View as="div" margin="small">
<Button onClick={() => setIsModalOpen(true)}>Open Modal</Button>
</View>
<CustomRecurrenceModal
eventStart={args.eventStart}
locale={args.locale}
timezone={args.timezone}
courseEndAt={args.courseEndAt}
RRULE={currRRULE}
isOpen={isModalOpen}
onDismiss={() => {
setIsModalOpen(false)
}}
onSave={handleChange}
/>
<div style={{margin: '.75rem 0', lineHeight: 1.5}}>
<Text as="div">eventStart: {args.eventStart}</Text>
<Text as="div">result: {currRRULE}</Text>
</div>
</div>
)
}
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)
)}`,
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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 (
<div>
<p>{I18n.t('There was an error loading the custom recurrence editor')}</p>
<p>{this.state.errorMessage}</p>
</div>
)
}
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<RRuleHelperSpec>(() => {
return new RRuleHelper(RRuleHelper.parseString(RRULE)).spec
})
const [isValidState, setIsValidState] = useState<boolean>(() => 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 (
<>
<Button onClick={onDismiss}>{I18n.t('Cancel')}</Button>
<Button
interaction={isValidState ? 'enabled' : 'disabled'}
type="submit"
color="primary"
margin="0 0 0 x-small"
onClick={handleSave}
>
{I18n.t('Done')}
</Button>
</>
)
}, [handleSave, isValidState, onDismiss])
return (
<CanvasModal
label={I18n.t('Custom Recurrence')}
onDismiss={onDismiss}
open={isOpen}
footer={<Footer />}
shouldCloseOnDocumentClick={false}
>
<CustomRecurrenceErrorBoundary>
<CustomRecurrence
eventStart={eventStart}
locale={locale}
timezone={timezone}
courseEndAt={courseEndAt}
rruleSpec={currSpec}
onChange={handleChange}
/>
</CustomRecurrenceErrorBoundary>
</CanvasModal>
)
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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> = {}
): 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(<CustomRecurrenceModal {...defaultProps()} />)
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(<CustomRecurrenceModal {...defaultProps({onDismiss})} />)
getByText('Close').click()
expect(onDismiss).toHaveBeenCalled()
})
it('calls onDismiss when the cancel button is clicked', () => {
const onDismiss = jest.fn()
const {getByText} = render(<CustomRecurrenceModal {...defaultProps({onDismiss})} />)
getByText('Cancel').click()
expect(onDismiss).toHaveBeenCalled()
})
it('calls onSave when the Done button is clicked', () => {
const onSave = jest.fn()
const {getByText} = render(<CustomRecurrenceModal {...defaultProps({onSave})} />)
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(
<CustomRecurrenceModal {...defaultProps({onSave})} />
)
const interval = getByDisplayValue('1')
fireEvent.change(interval, {target: {value: '2'}})
getByText('Done').click()
expect(onSave).toHaveBeenCalledWith('FREQ=DAILY;INTERVAL=2;COUNT=5')
})
})

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
// 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> = {}): 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')
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<RecurrenceEndPicker> = 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 (
<div style={{maxWidth: '700px'}}>
<style>
button:focus {'{'} outline: 2px solid dodgerblue; {'}'}
</style>
<button type="button" onClick={e => e.target.focus()}>
tab stop before
</button>
<View as="div" margin="small">
<RecurrenceEndPicker
courseEndAt={args.courseEndAt}
dtstart={args.dtstart}
locale={args.locale}
timezone={args.timezone}
until={currUntil}
count={currCount}
onChange={handleChange}
/>
</View>
<button type="button" onClick={e => e.target.focus()}>
tab stop after
</button>
<div style={{margin: '.75rem 0', lineHeight: 1.5}}>
<Text as="div">{`until: ${moment.tz(currUntil, args.timezone).toISOString(true)}`}</Text>
<Text as="div">{`count: ${currCount}`}</Text>
</div>
</div>
)
}
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',
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<string>(() => {
return dtstart
? moment.tz(dtstart, timezone).toISOString(true)
: moment().tz(timezone).toISOString(true)
})
const [mode, setMode] = useState<ModeValues>(() => {
if (until) return 'ON'
return 'AFTER'
})
const [untilDate, setUntilDate] = useState<string | undefined>(() => {
// 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<number | undefined>(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<HTMLInputElement>): 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 (
<FormFieldGroup description={I18n.t('Ends:')} layout="stacked" rowSpacing="small">
<div style={gridStyle}>
<div style={alignMe}>
<RadioInput
name="end"
value="ON"
label={I18n.t('on')}
checked={mode === 'ON'}
onChange={handleModeChange}
/>
</div>
<CanvasDateInput
interaction={mode === 'ON' ? 'enabled' : 'disabled'}
locale={locale}
timezone={timezone}
renderLabel={<ScreenReaderContent>{I18n.t('date')}</ScreenReaderContent>}
selectedDate={untilDate}
formatDate={date => dateFormatter.format(date)}
onSelectedDateChange={handleDateChange}
messages={
courseEndAt
? [
{
type: 'hint',
text: I18n.t('Course ends %{endat}', {endat: formatCourseEndDate(courseEndAt)}),
},
]
: undefined
}
/>
<div style={alignMe}>
<RadioInput
name="end"
value="AFTER"
label={I18n.t('after')}
checked={mode === 'AFTER'}
onChange={handleModeChange}
/>
</div>
<div style={{display: 'flex', alignItems: 'center', gap: '0.5rem'}}>
<NumberInput
display="inline-block"
interaction={mode === 'AFTER' ? 'enabled' : 'disabled'}
messages={[{type: 'hint', text: I18n.t('Maximum %{max}', {max: MAX_COUNT})}]}
renderLabel={<ScreenReaderContent>{I18n.t('occurences')}</ScreenReaderContent>}
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)
}}
/>
<div style={alignMe}>
<Text as="span">{I18n.t('occurrences')}</Text>
</div>
</div>
</div>
</FormFieldGroup>
)
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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> = {}
): 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(<RecurrenceEndPicker {...props} />)
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(
<RecurrenceEndPicker {...defaultProps({onChange, until: enddate})} />
)
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(<RecurrenceEndPicker {...props} />)
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(<RecurrenceEndPicker {...props} />)
const countinput = getByDisplayValue('5')
act(() => {
fireEvent.change(countinput, {target: {value: '6'}})
})
expect(onChange).toHaveBeenCalledWith({
until: undefined,
count: 6,
})
})
})

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<RepeatPicker> = args => {
const [interval, setInterval] = useState<Number>(args.interval)
const [freq, setFreq] = useState(args.freq)
const [weekdays, setWeekdays] = useState<SelectedDaysArray | undefined>(args.weekdays)
const [monthdate, setMonthdate] = useState<Number | undefined>(args.monthdate)
const [pos, setPos] = useState<number | undefined>(null)
const handleChange = useCallback((newVal: OnRepeatPickerChangeType): void => {
setInterval(newVal.interval)
setFreq(newVal.freq)
setWeekdays(newVal.weekdays)
setMonthdate(newVal.monthdate)
setPos(newVal.pos)
}, [])
return (
<div style={{maxWidth: '700px'}}>
<style>
button:focus {'{'} outline: 2px solid dodgerblue; {'}'}
</style>
<button type="button" onClick={e => e.target.focus()}>
tab stop before
</button>
<View as="div" margin="small">
<RepeatPicker
locale={args.locale}
timezone={args.timezone}
dtstart={args.dtstart}
interval={interval}
freq={freq}
weekdays={weekdays}
monthdate={monthdate}
pos={pos}
onChange={handleChange}
/>
</View>
<button type="button" onClick={e => e.target.focus()}>
tab stop after
</button>
<div
style={{
margin: '.75rem 0',
lineHeight: 1.5,
paddingTop: '.75rem',
borderTop: '1px solid grey',
}}
>
<Text as="div">{`interval: ${interval}`}</Text>
<Text as="div">{`freq: ${freq}`}</Text>
<Text as="div">{`weekdays: ${weekdays}`}</Text>
<Text as="div">{`monthdate: ${monthdate}`}</Text>
<Text as="div">{`pos: ${pos}`}</Text>
</div>
</div>
)
}
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',
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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 = `<div><div>${strings.join('</div><div>')}</div></div>`
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.Moment>(moment(dtstart).tz(timezone))
const [currInterval, setCurrInterval] = useState<number>(interval)
const [currFreq, setCurrFreq] = useState<FrequencyValue>(freq)
const [currWeekDays, setCurrWeekdays] = useState<SelectedDaysArray>(
weekdays ?? weekdaysFromMoment(eventStart)
)
const [currPos, setCurrPos] = useState<number | undefined>(pos)
const [currMonthlyMode, setCurrMonthlyMode] = useState<MonthlyModeValue>(() => {
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<string>(() => {
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<string>(() => {
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<HTMLElement | null>(null)
const freqRef = useRef<HTMLInputElement | null>(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 (
<div>
<fieldset style={{borderStyle: 'none', margin: 0, padding: '0'}}>
<legend style={{marginBottom: '.75rem'}}>
<span style={{whiteSpace: 'nowrap'}}>
<Text weight="bold">{I18n.t('Repeat every:')}</Text>
</span>
</legend>
<div
style={{
display: 'flex',
gap: '.5rem',
alignItems: 'center',
justifyContent: 'flex-start',
}}
>
<span style={{flexShrink: 1}}>
<NumberInput
data-testid="repeat-interval"
display="inline-block"
renderLabel={<ScreenReaderContent>{I18n.t('every')}</ScreenReaderContent>}
value={interval}
width={`${px('1em') + px('4rem')}px`}
onChange={handleIntervalChange}
onIncrement={event => {
handleIntervalChange(event, interval + 1)
}}
onDecrement={event => {
handleIntervalChange(event, interval - 1)
}}
/>
</span>
<span style={{minWidth: '7rem', flexShrink: 1}}>
<SimpleSelect
data-testid="repeat-frequency"
inputRef={(node: HTMLInputElement) => {
freqRef.current = node
}}
key={`${interval}-${freq}`}
renderLabel={<ScreenReaderContent>{I18n.t('frequency')}</ScreenReaderContent>}
assistiveText={I18n.t('Use arrow keys to navigate options.')}
value={freq}
width={freqPickerWidth}
onChange={handleFreqChange}
>
<SimpleSelectOption id="DAILY" value="DAILY">
{I18n.t('single_and_plural_days', {one: 'Day', other: 'Days'}, {count: interval})}
</SimpleSelectOption>
<SimpleSelectOption id="WEEKLY" value="WEEKLY">
{I18n.t(
'single_and_plural_weeks',
{one: 'Week', other: 'Weeks'},
{count: interval}
)}
</SimpleSelectOption>
<SimpleSelectOption id="MONTHLY" value="MONTHLY">
{I18n.t(
'single_and_plural_months',
{one: 'Month', other: 'Months'},
{count: interval}
)}
</SimpleSelectOption>
<SimpleSelectOption id="YEARLY" value="YEARLY">
{I18n.t(
'single_and_plural_years',
{one: 'Year', other: 'Years'},
{count: interval}
)}
</SimpleSelectOption>
</SimpleSelect>
</span>
{freq === 'MONTHLY' && (
<div style={{flexGrow: 1, flexShrink: 0, flexBasis: 'min-content'}}>
<SimpleSelect
key={eventStart.toISOString(true)}
data-testid="repeat-month-mode"
renderLabel={<ScreenReaderContent>{I18n.t('day of month')}</ScreenReaderContent>}
assistiveText={I18n.t('Use arrow keys to navigate options.')}
value={currMonthlyMode}
width={monthlyOptionsWidth}
onChange={handleChangeMonthlyMode}
>
<SimpleSelectOption id="BYMONTHDATE" value="BYMONTHDATE">
{I18n.t('on day %{date}', {date: eventStart.date()})}
</SimpleSelectOption>
<SimpleSelectOption id="BYMONTHDAY" value="BYMONTHDAY">
{getByMonthdateString(eventStart, locale, timezone)}
</SimpleSelectOption>
{isLastWeekdayInMonth(eventStart) && (
<SimpleSelectOption id="BYLASTMONTHDAY" value="BYLASTMONTHDAY">
{getLastWeekdayInMonthString(getWeekdayName(eventStart, locale, timezone))}
</SimpleSelectOption>
)}
</SimpleSelect>
</div>
)}
{freq === 'YEARLY' && <Text>{yearlyFreqToText()}</Text>}
</div>
</fieldset>
{freq === 'WEEKLY' && (
<View as="div" margin="small 0">
<WeekdayPicker
data-testid="repeat-weekday"
locale={locale}
selectedDays={weekdays || [AllRRULEDayValues[eventStart.day()]]}
onChange={handleWeekdayChange}
/>
</View>
)}
</div>
)
}

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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> = {}): 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(
<RepeatPicker {...defaultProps()} />
)
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(<RepeatPicker {...defaultProps({interval: 2})} />)
expect(getByDisplayValue('2')).toBeInTheDocument()
expect(getByDisplayValue('Days')).toBeInTheDocument()
})
it('renders weekly', () => {
const {getByDisplayValue} = render(<RepeatPicker {...defaultProps({freq: 'WEEKLY'})} />)
expect(getByDisplayValue('1')).toBeInTheDocument()
expect(getByDisplayValue('Week')).toBeInTheDocument()
})
it('renders plural weeks', () => {
const {getByDisplayValue} = render(
<RepeatPicker {...defaultProps({interval: 2, freq: 'WEEKLY'})} />
)
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(<RepeatPicker {...defaultProps({freq: 'MONTHLY'})} />)
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(
<RepeatPicker {...defaultProps({freq: 'MONTHLY', weekdays: today_day, pos})} />
)
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(
<RepeatPicker
{...defaultProps({dtstart: '2023-06-30', freq: 'MONTHLY', weekdays: ['FR'], pos: -1})}
/>
)
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(
<RepeatPicker {...defaultProps({dtstart: '2023-07-25', freq: 'MONTHLY'})} />
)
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(
<RepeatPicker {...defaultProps({freq: 'YEARLY'})} />
)
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(<RepeatPicker {...defaultProps({onChange})} />)
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(<RepeatPicker {...defaultProps({onChange})} />)
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(
<RepeatPicker {...defaultProps({freq: 'WEEKLY', onChange})} />
)
// 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,
})
})
})
})

View File

@ -30,7 +30,7 @@ I18n.translations['zh-Hant'] = {
}
export default {
title: 'Examples/Calendar/WeekdayPicker',
title: 'Examples/Calendar/RecurringEvents/WeekdayPicker',
component: WeekdayPicker,
} as Meta

View File

@ -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<WeekDaysSpec>({

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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')
})
})
})

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
export type UnknownSubset<T> = {
[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']

View File

@ -71,7 +71,7 @@ function CanvasModal({
<FlexItem grow={true}>
<Heading>{title}</Heading>
</FlexItem>
<FlexItem>
<FlexItem margin="0 0 0 x-small">
<CloseButton
onClick={onDismiss}
size={closeButtonSize}