diff --git a/grafana-plugin/src/components/Modal/Modal.module.css b/grafana-plugin/src/components/Modal/Modal.module.css index 683a78ad..d4cafe6a 100644 --- a/grafana-plugin/src/components/Modal/Modal.module.css +++ b/grafana-plugin/src/components/Modal/Modal.module.css @@ -17,17 +17,21 @@ border: var(--border-weak); box-shadow: var(--shadows-z3); border-radius: 2px; + z-index: 10; } +/* + .overlay { - position: fixed; + position: relative; inset: 0; z-index: 10; - - /* background-color: rgba(0, 0, 0, 0.45); - backdrop-filter: blur(1px); */ + background-color: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(1px); } .body-open { overflow: hidden; } + + */ diff --git a/grafana-plugin/src/components/Modal/Modal.tsx b/grafana-plugin/src/components/Modal/Modal.tsx index 869a3a25..e5c08d9f 100644 --- a/grafana-plugin/src/components/Modal/Modal.tsx +++ b/grafana-plugin/src/components/Modal/Modal.tsx @@ -39,7 +39,8 @@ const Modal: FC> = (props) => { contentLabel={title} className={cx('root')} overlayClassName={cx('overlay')} - bodyOpenClassName={cx('body-open')} + overlayElement={(props, contentElement) => contentElement} // render without overlay to allow body scroll + /* bodyOpenClassName={cx('body-open')} */ contentElement={contentElement} > {children} diff --git a/grafana-plugin/src/components/Table/Table.module.css b/grafana-plugin/src/components/Table/Table.module.css index 50e50ffc..e4ba3005 100644 --- a/grafana-plugin/src/components/Table/Table.module.css +++ b/grafana-plugin/src/components/Table/Table.module.css @@ -6,6 +6,10 @@ width: 100%; } +.root table tbody tr.row-even { + background: var(--background-secondary); +} + .root tr { min-height: 56px; } @@ -20,7 +24,8 @@ .root td { min-height: 60px; - padding: 10px 0; + padding-top: 10px; + padding-bottom: 10px; } .pagination { @@ -37,6 +42,11 @@ transition: transform 0.2s; } +/* to allow expand on expand-button click */ +.root table :global(.rc-table-row-expand-icon-cell) > span { + pointer-events: none; +} + .expand-icon__expanded { transform: rotate(0deg); } diff --git a/grafana-plugin/src/components/Table/Table.tsx b/grafana-plugin/src/components/Table/Table.tsx index dbc5e652..8c656de5 100644 --- a/grafana-plugin/src/components/Table/Table.tsx +++ b/grafana-plugin/src/components/Table/Table.tsx @@ -37,24 +37,31 @@ const GTable: FC = (props) => { const { page, total: numberOfPages, onChange: onNavigate } = pagination || {}; - if (expandable) { - expandable.expandIcon = ({ expanded, record }) => { - return ( -
- -
- ); - }; - } + const expandableFn = useMemo(() => { + return expandable + ? { + ...expandable, + expandIcon: ({ expanded, record }) => { + return ( +
+ +
+ ); + }, + expandedRowClassName: (record, index) => (index % 2 === 0 ? cx('row-even') : cx('row-odd')), + } + : null; + }, [expandable]); return ( (index % 2 === 0 ? cx('row-even') : cx('row-odd'))} {...restProps} /> {pagination && ( diff --git a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx index 1ae46c1e..82244568 100644 --- a/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx +++ b/grafana-plugin/src/components/TimelineMarks/TimelineMarks.tsx @@ -17,6 +17,8 @@ const cx = cn.bind(styles); const TimelineMarks: FC = (props) => { const { startMoment, debug } = props; + const currentMoment = useMemo(() => dayjs(), []); + const momentsToRender = useMemo(() => { const hoursToSplit = 12; @@ -60,10 +62,14 @@ const TimelineMarks: FC = (props) => { )} {momentsToRender.map((m, i) => { + const isCurrentDay = currentMoment.isSame(m.moment, 'day'); + return (
- {m.moment.format('ddd D MMM')} + + {m.moment.format('ddd D MMM')} +
{m.moments.map((mm, j) => ( diff --git a/grafana-plugin/src/components/WithConfirm/WithConfirm.tsx b/grafana-plugin/src/components/WithConfirm/WithConfirm.tsx index 0419e8f3..67a53cd7 100644 --- a/grafana-plugin/src/components/WithConfirm/WithConfirm.tsx +++ b/grafana-plugin/src/components/WithConfirm/WithConfirm.tsx @@ -20,7 +20,9 @@ const WithConfirm = (props: WithConfirmProps) => { const [showConfirmation, setShowConfirmation] = useState(false); - const onClickCallback = useCallback(() => { + const onClickCallback = useCallback((event) => { + event.stopPropagation(); + setShowConfirmation(true); }, []); diff --git a/grafana-plugin/src/containers/RotationForm/DateTimePicker.tsx b/grafana-plugin/src/containers/RotationForm/DateTimePicker.tsx index 1857b5c7..c8174b94 100644 --- a/grafana-plugin/src/containers/RotationForm/DateTimePicker.tsx +++ b/grafana-plugin/src/containers/RotationForm/DateTimePicker.tsx @@ -44,23 +44,19 @@ const DateTimePicker = (props: UserTooltipProps) => { const minDate = useMemo(() => (minMoment ? toDate(minMoment, timezone) : undefined), [minMoment, timezone]); - const handleDateChange = useCallback( - (newDate: Date) => { - const localMoment = dayjs().tz(timezone).utcOffset() === 0 ? dayjs().utc() : dayjs().tz(timezone); + const handleDateChange = (newDate: Date) => { + const localMoment = dayjs().tz(timezone).utcOffset() === 0 ? dayjs().utc() : dayjs().tz(timezone); - const newValue = localMoment - .set('year', newDate.getFullYear()) - .set('month', newDate.getMonth()) - .set('date', newDate.getDate()) - .set('hour', value.getHours()) - .set('minute', value.getMinutes()) - .set('second', value.getSeconds()); - - onChange(newValue); - }, - [value] - ); + const newValue = localMoment + .set('year', newDate.getFullYear()) + .set('month', newDate.getMonth()) + .set('date', newDate.getDate()) + .set('hour', value.getHours()) + .set('minute', value.getMinutes()) + .set('second', value.getSeconds()); + onChange(newValue); + }; const handleTimeChange = useCallback( (newMoment: DateTime) => { const localMoment = dayjs().tz(timezone).utcOffset() === 0 ? dayjs().utc() : dayjs().tz(timezone); diff --git a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx index ffc2ada9..c370c2b8 100644 --- a/grafana-plugin/src/containers/RotationForm/RotationForm.tsx +++ b/grafana-plugin/src/containers/RotationForm/RotationForm.tsx @@ -96,6 +96,16 @@ const RotationForm: FC = observer((props) => { } }, [rotationStart, shiftStart]); + const updateShiftStart = useCallback( + (value) => { + const diff = shiftEnd.diff(shiftStart); + + setShiftStart(value); + setShiftEnd(value.add(diff)); + }, + [shiftStart, shiftEnd] + ); + const store = useStore(); const shift = store.scheduleStore.shifts[shiftId]; @@ -247,8 +257,6 @@ const RotationForm: FC = observer((props) => { const isFormValid = useMemo(() => userGroups.some((group) => group.length), [userGroups]); - const moment = dayjs(); - return ( = observer((props) => { className={cx('date-time-picker')} label={ - Shift start + Parent shift start } > - + - Shift end + Parent shift end } > diff --git a/grafana-plugin/src/containers/Rotations/Rotations.tsx b/grafana-plugin/src/containers/Rotations/Rotations.tsx index 61181b84..5b16fa84 100644 --- a/grafana-plugin/src/containers/Rotations/Rotations.tsx +++ b/grafana-plugin/src/containers/Rotations/Rotations.tsx @@ -37,6 +37,7 @@ interface RotationsProps extends WithStoreProps { onCreate: () => void; onUpdate: () => void; onDelete: () => void; + disabled: boolean; } interface RotationsState { @@ -52,7 +53,17 @@ class Rotations extends Component { }; render() { - const { scheduleId, startMoment, currentTimezone, onCreate, onUpdate, onDelete, store, shiftIdToShowRotationForm } = this.props; + const { + scheduleId, + startMoment, + currentTimezone, + onCreate, + onUpdate, + onDelete, + store, + shiftIdToShowRotationForm, + disabled, + } = this.props; const { layerPriority, shiftMomentToShowRotationForm } = this.state; const base = 7 * 24 * 60; // in minutes @@ -87,13 +98,19 @@ class Rotations extends Component { Rotations
- + {disabled ? ( + + ) : ( + + )}
@@ -217,19 +234,35 @@ class Rotations extends Component { } onRotationClick = (shiftId: Shift['id'], moment?: dayjs.Dayjs) => { + const { disabled } = this.props; + + if (disabled) { + return; + } + this.setState({ shiftMomentToShowRotationForm: moment }, () => { this.onShowRotationForm(shiftId); }); }; handleAddLayer = (layerPriority: number, moment?: dayjs.Dayjs) => { + const { disabled } = this.props; + + if (disabled) { + return; + } + this.setState({ layerPriority, shiftMomentToShowRotationForm: moment }, () => { this.onShowRotationForm('new'); }); }; handleAddRotation = (option: SelectableValue) => { - const { startMoment } = this.props; + const { startMoment, disabled } = this.props; + + if (disabled) { + return; + } this.setState( { diff --git a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx index 12e1a515..e9e4048e 100644 --- a/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx +++ b/grafana-plugin/src/containers/Rotations/ScheduleOverrides.tsx @@ -38,6 +38,7 @@ interface ScheduleOverridesProps extends WithStoreProps { onCreate: () => void; onUpdate: () => void; onDelete: () => void; + disabled: boolean; } interface ScheduleOverridesState { @@ -51,8 +52,17 @@ class ScheduleOverrides extends Component
- @@ -154,13 +164,23 @@ class ScheduleOverrides extends Component { + const { disabled } = this.props; + + if (disabled) { + return; + } + this.setState({ shiftMomentToShowOverrideForm: moment }, () => { this.onShowRotationForm(shiftId); }); }; handleAddOverride = () => { - const { startMoment } = this.props; + const { startMoment, disabled } = this.props; + + if (disabled) { + return; + } this.setState({ shiftMomentToShowOverrideForm: startMoment }, () => { this.onShowRotationForm('new'); diff --git a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts index fe40a251..62b6df1f 100644 --- a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts +++ b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.config.ts @@ -6,14 +6,16 @@ import { DEFAULT_USER_ROLES } from 'models/user/user.config'; const commonFields: FormItem[] = [ { - name: 'ical_url_overrides', - label: 'Overrides schedule iCal URL ', - type: FormItemType.TextArea, - description: - 'You can use an override calendar to share with your team members. Users can add \n' + - 'events to this calendar, and they will override existing events in the primary \n' + - 'calendar. The iCal URL for your override calendar can be found in the calendar \n' + - 'integration settings of your calendar service.', + name: 'team', + label: 'Assign to team', + type: FormItemType.GSelect, + extra: { + modelName: 'grafanaTeamStore', + displayField: 'name', + valueField: 'id', + showSearch: true, + allowClear: true, + }, }, { name: 'slack_channel_id', @@ -29,6 +31,19 @@ const commonFields: FormItem[] = [ description: 'Calendar parsing errors and notifications about the new on-call shift will be published in this channel.', }, + { + name: 'user_group', + label: 'Slack user group', + type: FormItemType.GSelect, + extra: { + modelName: 'userGroupStore', + displayField: 'handle', + showSearch: true, + allowClear: true, + }, + description: + 'Group members will be automatically updated with current on-call. In case you want to ping on-call with @group_name.', + }, { name: 'notify_oncall_shift_freq', label: 'Notification frequency', @@ -77,37 +92,12 @@ const commonFields: FormItem[] = [ }, description: 'Specify how to notify a team member when their shift is the next one scheduled', }, - { - name: 'user_group', - label: 'Slack user group', - type: FormItemType.GSelect, - extra: { - modelName: 'userGroupStore', - displayField: 'handle', - showSearch: true, - allowClear: true, - }, - description: - 'Group members will be automatically updated with current on-call. In case you want to ping on-call with @group_name.', - }, // { // name: 'send_empty_shifts_report', // normalize: (value) => Boolean(value), // label: 'Send reports about empty shifts to Slack', // type: FormItemType.Switch, // }, - { - name: 'team', - label: 'Assign to team', - type: FormItemType.GSelect, - extra: { - modelName: 'grafanaTeamStore', - displayField: 'name', - valueField: 'id', - showSearch: true, - allowClear: true, - }, - }, ]; export const iCalForm: { name: string; fields: FormItem[] } = { @@ -128,6 +118,16 @@ export const iCalForm: { name: string; fields: FormItem[] } = { 'access. The iCal URL for your primary calendar can be found in the calendar \n' + 'integration settings of your calendar service.', }, + { + name: 'ical_url_overrides', + label: 'Overrides schedule iCal URL ', + type: FormItemType.TextArea, + description: + 'You can use an override calendar to share with your team members. Users can add \n' + + 'events to this calendar, and they will override existing events in the primary \n' + + 'calendar. The iCal URL for your override calendar can be found in the calendar \n' + + 'integration settings of your calendar service.', + }, ...commonFields, ], }; @@ -140,6 +140,16 @@ export const calendarForm: { name: string; fields: FormItem[] } = { type: FormItemType.Input, validation: { required: true }, }, + { + name: 'ical_url_overrides', + label: 'Overrides schedule iCal URL ', + type: FormItemType.TextArea, + description: + 'You can use an override calendar to share with your team members. Users can add \n' + + 'events to this calendar, and they will override existing events in the primary \n' + + 'calendar. The iCal URL for your override calendar can be found in the calendar \n' + + 'integration settings of your calendar service.', + }, ...commonFields, ], }; @@ -152,5 +162,6 @@ export const apiForm: { name: string; fields: FormItem[] } = { type: FormItemType.Input, validation: { required: true }, }, + ...commonFields, ], }; diff --git a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx index 2c58562e..fe84dfeb 100644 --- a/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx +++ b/grafana-plugin/src/containers/ScheduleForm/ScheduleForm.tsx @@ -78,10 +78,10 @@ const ScheduleForm = observer((props: ScheduleFormProps) => { >
- + {/* Manage on-call schedules using your favourite calendar app, such as Google Calendar or Microsoft Outlook. To schedule on-call shifts create a new calendar and use events with the teammates usernames - + */} - - - + <> +
+ +
+ + + + + + + {schedule?.name} + + {/* + Grafana 1 +
+ Grafana 2 +
+ Grafana 3 + + } + /> + */} +
+ + {users && ( + + Current timezone: + + + )} + {/**/} + {/* + + + */} + + { + this.setState({ showEditForm: true }); + }} + /> + + + + - - {startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')} -
- -
-
- - - -
-
-
+
+
+ +
+ + {/*
*/} +
+
+ + + + + + + + + {startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')} + + + {/* + + + */} + +
+ + + +
+ +
+ {showEditForm && ( + { + this.setState({ showEditForm: false }); + }} + /> + )} + ); } + update = () => { + const { store, query } = this.props; + const { id: scheduleId } = query; + const { scheduleStore } = store; + + return scheduleStore.updateItem(scheduleId); + }; + handleShowForm = async (shiftId: Shift['id'] | 'new') => { const { store: { scheduleStore }, @@ -191,17 +280,29 @@ class SchedulePage extends React.Component const shift = await scheduleStore.updateOncallShift(shiftId); if (shift.type === 2) { - this.setState({ shiftIdToShowRotationForm: shiftId }); + this.handleShowRotationForm(shiftId); } else if (shift.type === 3) { - this.setState({ shiftIdToShowOverridesForm: shiftId }); + this.handleShowOverridesForm(shiftId); } }; handleShowRotationForm = (shiftId: Shift['id'] | 'new') => { + const { shiftIdToShowRotationForm, shiftIdToShowOverridesForm } = this.state; + + if (shiftId && (shiftIdToShowRotationForm || shiftIdToShowOverridesForm)) { + return; + } + this.setState({ shiftIdToShowRotationForm: shiftId }); }; handleShowOverridesForm = (shiftId: Shift['id'] | 'new') => { + const { shiftIdToShowRotationForm, shiftIdToShowOverridesForm } = this.state; + + if (shiftId && (shiftIdToShowRotationForm || shiftIdToShowOverridesForm)) { + return; + } + this.setState({ shiftIdToShowOverridesForm: shiftId }); }; diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css b/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css index 4857e7cc..380b25a3 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.module.css @@ -15,6 +15,10 @@ padding-left: 20px; } +.root .buttons { + padding-right: 10px; +} + /* .root .expanded-row { background: var(--secondary-background); diff --git a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx index 895f730e..8c7ed34a 100644 --- a/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx +++ b/grafana-plugin/src/pages/schedules_NEW/Schedules.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { SyntheticEvent } from 'react'; import { getLocationSrv } from '@grafana/runtime'; import { Button, HorizontalGroup, IconButton, LoadingPlaceholder, VerticalGroup } from '@grafana/ui'; @@ -19,12 +19,16 @@ import TimelineMarks from 'components/TimelineMarks/TimelineMarks'; import UserTimezoneSelect from 'components/UserTimezoneSelect/UserTimezoneSelect'; import WithConfirm from 'components/WithConfirm/WithConfirm'; import ScheduleFinal from 'containers/Rotations/ScheduleFinal'; +import ScheduleForm from 'containers/ScheduleForm/ScheduleForm'; +import { WithPermissionControl } from 'containers/WithPermissionControl/WithPermissionControl'; import { getFromString } from 'models/schedule/schedule.helpers'; import { Schedule, ScheduleType } from 'models/schedule/schedule.types'; +import { getSlackChannelName } from 'models/slack_channel/slack_channel.helpers'; import { Timezone } from 'models/timezone/timezone.types'; import { getStartOfWeek } from 'pages/schedule/Schedule.helpers'; import { AppFeature } from 'state/features'; import { WithStoreProps } from 'state/types'; +import { UserAction } from 'state/userAction'; import { withMobXProviderContext } from 'state/withStore'; import styles from './Schedules.module.css'; @@ -38,6 +42,7 @@ interface SchedulesPageState { filters: SchedulesFiltersType; showNewScheduleSelector: boolean; expandedRowKeys: Array; + scheduleIdToEdit?: Schedule['id']; } @observer @@ -51,6 +56,7 @@ class SchedulesPage extends React.Component )} + {scheduleIdToEdit && ( + { + this.setState({ scheduleIdToEdit: undefined }); + }} + /> + )} ); } @@ -227,6 +271,14 @@ class SchedulesPage extends React.Component { + type tTypeToVerbal = { + [key: number]: string; + }; + const typeToVerbal: tTypeToVerbal = { 0: 'API/Terraform', 1: 'Ical', 2: 'Web' }; + return typeToVerbal[value]; + }; + renderStatus = (item: Schedule) => { const { store: { scheduleStore }, @@ -293,6 +345,14 @@ class SchedulesPage extends React.Component { + return getSlackChannelName(value.slack_channel) || '-'; + }; + + renderUserGroup = (value: Schedule) => { + return value.user_group?.handle || '-'; + }; + /* renderChatOps = (item: Schedule) => { return item.chatOps; }; */ @@ -309,18 +369,33 @@ class SchedulesPage extends React.Component */} - - - + + + + + + + + ); }; + getEditScheduleClickHandler = (id: Schedule['id']) => { + return (event) => { + event.stopPropagation(); + + this.setState({ scheduleIdToEdit: id }); + }; + }; + getDeleteScheduleClickHandler = (id: Schedule['id']) => { const { store } = this.props; const { scheduleStore } = store; - return () => { + return (event: SyntheticEvent) => { + event.stopPropagation(); + scheduleStore.delete(id).then(this.update); }; };