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:
parent
ea018d3547
commit
9bcea1f53c
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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: '',
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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'),
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
)}`,
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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',
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -30,7 +30,7 @@ I18n.translations['zh-Hant'] = {
|
|||
}
|
||||
|
||||
export default {
|
||||
title: 'Examples/Calendar/WeekdayPicker',
|
||||
title: 'Examples/Calendar/RecurringEvents/WeekdayPicker',
|
||||
component: WeekdayPicker,
|
||||
} as Meta
|
||||
|
|
@ -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>({
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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']
|
|
@ -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}
|
||||
|
|
Loading…
Reference in New Issue