# What this PR does

## Which issue(s) this PR fixes

## Checklist

- [ ] Tests updated
- [ ] Documentation added
- [x] `CHANGELOG.md` updated
This commit is contained in:
Maxim Mordasov 2023-02-27 19:27:11 +03:00 committed by GitHub
parent c0873007b0
commit bee9943706
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 336 additions and 269 deletions

View file

@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed UI permission related bug where Editors could not export their user iCal link - Fixed UI permission related bug where Editors could not export their user iCal link
- Fixed error when a shift is created using Etc/UTC as timezone - Fixed error when a shift is created using Etc/UTC as timezone
- Fixed issue with refresh ical file task not considering empty string values - Fixed issue with refresh ical file task not considering empty string values
- Schedules: Long popup does not fit screen & buttons unreachable & objects outside of the popup ([1002](https://github.com/grafana/oncall/issues/1002))
- Can't scroll on integration settings page ([415](https://github.com/grafana/oncall/issues/415))
- Team change in the Integration page always causes 403 ([1292](https://github.com/grafana/oncall/issues/1292))
- Schedules: Permalink doesn't work with multi-teams ([940](https://github.com/grafana/oncall/issues/940))
- Schedules list -> expanded schedule blows page width ([1293](https://github.com/grafana/oncall/issues/1293))
### Changed ### Changed

View file

@ -7,7 +7,7 @@
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
top: 10%; top: 10%;
max-height: 80%; max-height: 90%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-image: initial; border-image: initial;
@ -18,6 +18,7 @@
box-shadow: var(--shadows-z3); box-shadow: var(--shadows-z3);
border-radius: 2px; border-radius: 2px;
z-index: 10; z-index: 10;
overflow: scroll;
} }
/* /*

View file

@ -48,6 +48,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1px; gap: 1px;
max-height: 300px;
overflow: scroll;
} }
.user { .user {
@ -55,7 +57,6 @@
border-radius: 2px; border-radius: 2px;
display: flex; display: flex;
position: relative; position: relative;
overflow: hidden;
} }
.user-buttons { .user-buttons {

View file

@ -2,6 +2,15 @@
display: block; display: block;
} }
.title {
background: var(--background-primary);
top: -15px;
position: sticky;
margin: -15px -15px 0 -15px;
padding: 15px;
z-index: 10;
}
.draggable { .draggable {
top: 0; top: 0;

View file

@ -277,151 +277,155 @@ const RotationForm: FC<RotationFormProps> = observer((props) => {
</Draggable> </Draggable>
)} )}
> >
<VerticalGroup> <>
<HorizontalGroup justify="space-between"> <div className={cx('title')}>
<Text size="medium"> <HorizontalGroup justify="space-between">
<HorizontalGroup spacing="sm"> <Text size="medium">
<span>[L{shiftId === 'new' ? layerPriority : shift?.priority_level}]</span> <HorizontalGroup spacing="sm">
{shiftId === 'new' ? 'New Rotation' : 'Update Rotation'} <span>[L{shiftId === 'new' ? layerPriority : shift?.priority_level}]</span>
</HorizontalGroup> {shiftId === 'new' ? 'New Rotation' : 'Update Rotation'}
</Text> </HorizontalGroup>
<HorizontalGroup> </Text>
{shiftId !== 'new' && (
<IconButton
variant="secondary"
tooltip="Delete"
name="trash-alt"
onClick={() => setShowDeleteRotationConfirmation(true)}
/>
)}
<IconButton variant="secondary" className={cx('drag-handler')} name="draggabledots" />
</HorizontalGroup>
</HorizontalGroup>
<div className={cx('content')}>
<VerticalGroup>
<div className={cx('two-fields')}>
<Field
label={
<Text type="primary" size="small">
Rotation start
</Text>
}
>
<DateTimePicker
minMoment={shiftStart}
value={rotationStart}
onChange={setRotationStart}
timezone={currentTimezone}
onFocus={getFocusHandler('rotationStart')}
onBlur={handleBlur}
/>
</Field>
<Field
label={
<HorizontalGroup spacing="xs">
<Text type="primary" size="small">
Rotation end
</Text>
<InlineSwitch
className={cx('inline-switch')}
transparent
value={!endLess}
onChange={handleChangeEndless}
/>
</HorizontalGroup>
}
>
{endLess ? (
<div style={{ lineHeight: '32px' }}>
<Text type="secondary">Endless</Text>
</div>
) : (
<DateTimePicker value={rotationEnd} onChange={setRotationEnd} timezone={currentTimezone} />
)}
</Field>
</div>
<HorizontalGroup> <HorizontalGroup>
<Field className={cx('control')} label="Repeat shifts every"> {shiftId !== 'new' && (
<Select <IconButton
maxMenuHeight={120} variant="secondary"
value={repeatEveryValue} tooltip="Delete"
options={repeatShiftsEveryOptions} name="trash-alt"
onChange={handleRepeatEveryValueChange} onClick={() => setShowDeleteRotationConfirmation(true)}
allowCustomValue
/> />
</Field> )}
<Field className={cx('control')} label=""> <IconButton variant="secondary" className={cx('drag-handler')} name="draggabledots" />
<RemoteSelect
href="/oncall_shifts/frequency_options/"
value={repeatEveryPeriod}
onChange={setRepeatEveryPeriod}
/>
</Field>
</HorizontalGroup> </HorizontalGroup>
{(repeatEveryPeriod === 0 || repeatEveryPeriod === 1) && (
<Field label="Select days to repeat">
<DaysSelector
options={store.scheduleStore.byDayOptions}
value={selectedDays}
onChange={(value) => setSelectedDays(value)}
/>
</Field>
)}
<div className={cx('two-fields')}>
<Field
className={cx('date-time-picker')}
label={
<Text type="primary" size="small">
Parent shift start
</Text>
}
>
<DateTimePicker
value={shiftStart}
onChange={updateShiftStart}
timezone={currentTimezone}
onFocus={getFocusHandler('shiftStart')}
onBlur={handleBlur}
/>
</Field>
<Field
className={cx('date-time-picker')}
label={
<Text type="primary" size="small">
Parent shift end
</Text>
}
>
<DateTimePicker
value={shiftEnd}
onChange={setShiftEnd}
timezone={currentTimezone}
onFocus={getFocusHandler('shiftEnd')}
onBlur={handleBlur}
/>
</Field>
</div>
<UserGroups
value={userGroups}
onChange={setUserGroups}
isMultipleGroups={true}
renderUser={renderUser}
showError={!isFormValid}
/>
</VerticalGroup>
</div>
<HorizontalGroup justify="space-between">
<Text type="secondary">Timezone: {getTzOffsetString(dayjs().tz(currentTimezone))}</Text>
<HorizontalGroup>
<Button variant="secondary" onClick={onHide}>
{shiftId === 'new' ? 'Cancel' : 'Close'}
</Button>
<Button variant="primary" onClick={handleCreate} disabled={!isFormValid}>
{shiftId === 'new' ? 'Create' : 'Update'}
</Button>
</HorizontalGroup> </HorizontalGroup>
</HorizontalGroup> </div>
</VerticalGroup> <VerticalGroup>
<div className={cx('content')}>
<VerticalGroup>
<div className={cx('two-fields')}>
<Field
label={
<Text type="primary" size="small">
Rotation start
</Text>
}
>
<DateTimePicker
minMoment={shiftStart}
value={rotationStart}
onChange={setRotationStart}
timezone={currentTimezone}
onFocus={getFocusHandler('rotationStart')}
onBlur={handleBlur}
/>
</Field>
<Field
label={
<HorizontalGroup spacing="xs">
<Text type="primary" size="small">
Rotation end
</Text>
<InlineSwitch
className={cx('inline-switch')}
transparent
value={!endLess}
onChange={handleChangeEndless}
/>
</HorizontalGroup>
}
>
{endLess ? (
<div style={{ lineHeight: '32px' }}>
<Text type="secondary">Endless</Text>
</div>
) : (
<DateTimePicker value={rotationEnd} onChange={setRotationEnd} timezone={currentTimezone} />
)}
</Field>
</div>
<HorizontalGroup>
<Field className={cx('control')} label="Repeat shifts every">
<Select
maxMenuHeight={120}
value={repeatEveryValue}
options={repeatShiftsEveryOptions}
onChange={handleRepeatEveryValueChange}
allowCustomValue
/>
</Field>
<Field className={cx('control')} label="">
<RemoteSelect
href="/oncall_shifts/frequency_options/"
value={repeatEveryPeriod}
onChange={setRepeatEveryPeriod}
/>
</Field>
</HorizontalGroup>
{(repeatEveryPeriod === 0 || repeatEveryPeriod === 1) && (
<Field label="Select days to repeat">
<DaysSelector
options={store.scheduleStore.byDayOptions}
value={selectedDays}
onChange={(value) => setSelectedDays(value)}
/>
</Field>
)}
<div className={cx('two-fields')}>
<Field
className={cx('date-time-picker')}
label={
<Text type="primary" size="small">
Parent shift start
</Text>
}
>
<DateTimePicker
value={shiftStart}
onChange={updateShiftStart}
timezone={currentTimezone}
onFocus={getFocusHandler('shiftStart')}
onBlur={handleBlur}
/>
</Field>
<Field
className={cx('date-time-picker')}
label={
<Text type="primary" size="small">
Parent shift end
</Text>
}
>
<DateTimePicker
value={shiftEnd}
onChange={setShiftEnd}
timezone={currentTimezone}
onFocus={getFocusHandler('shiftEnd')}
onBlur={handleBlur}
/>
</Field>
</div>
<UserGroups
value={userGroups}
onChange={setUserGroups}
isMultipleGroups={true}
renderUser={renderUser}
showError={!isFormValid}
/>
</VerticalGroup>
</div>
<HorizontalGroup justify="space-between">
<Text type="secondary">Timezone: {getTzOffsetString(dayjs().tz(currentTimezone))}</Text>
<HorizontalGroup>
<Button variant="secondary" onClick={onHide}>
{shiftId === 'new' ? 'Cancel' : 'Close'}
</Button>
<Button variant="primary" onClick={handleCreate} disabled={!isFormValid}>
{shiftId === 'new' ? 'Create' : 'Update'}
</Button>
</HorizontalGroup>
</HorizontalGroup>
</VerticalGroup>
</>
{showDeleteRotationConfirmation && ( {showDeleteRotationConfirmation && (
<GrafanaModal <GrafanaModal
isOpen isOpen

View file

@ -41,6 +41,8 @@
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
pointer-events: none; pointer-events: none;
position: absolute;
white-space: nowrap;
} }
.label { .label {
@ -54,7 +56,6 @@
font-weight: bold; font-weight: bold;
margin-right: 5px; margin-right: 5px;
flex-shrink: 0; flex-shrink: 0;
pointer-events: none;
} }
.details { .details {

View file

@ -123,12 +123,14 @@ const ScheduleSlot: FC<ScheduleSlotProps> = observer((props) => {
duration={duration} duration={duration}
/> />
)} )}
{userIndex === 0 && label && ( <div className={cx('title')}>
<div className={cx('label')} style={{ color }}> {userIndex === 0 && label && (
{label} <div className={cx('label')} style={{ color }}>
</div> {label}
)} </div>
<div className={cx('title')}>{title}</div> )}
{title}
</div>
</div> </div>
</Tooltip> </Tooltip>
); );

View file

@ -1,5 +1,9 @@
/* Navigation/Layout */ /* Navigation/Layout */
.drawer-content {
overflow: auto !important; /* fix https://github.com/grafana/oncall/issues/415 */
}
.page-body { .page-body {
max-width: unset !important; max-width: unset !important;
} }

View file

@ -147,6 +147,8 @@ export class ScheduleStore extends BaseStore {
...this.items, ...this.items,
[item.id]: item, [item.id]: item,
}; };
return item;
} }
} }

View file

@ -58,6 +58,14 @@ class Integrations extends React.Component<IntegrationsProps, IntegrationsState>
this.update().then(() => this.parseQueryParams(true)); this.update().then(() => this.parseQueryParams(true));
} }
componentDidUpdate(prevProps: Readonly<IntegrationsProps>): void {
if (prevProps.match.params.id && !this.props.match.params.id) {
this.setState({ errorData: initErrorDataState() }, () => {
this.parseQueryParams();
});
}
}
setSelectedAlertReceiveChannel = (alertReceiveChannelId: AlertReceiveChannel['id'], shouldRedirect = false) => { setSelectedAlertReceiveChannel = (alertReceiveChannelId: AlertReceiveChannel['id'], shouldRedirect = false) => {
const { store, history } = this.props; const { store, history } = this.props;
store.selectedAlertReceiveChannel = alertReceiveChannelId; store.selectedAlertReceiveChannel = alertReceiveChannelId;

View file

@ -27,3 +27,8 @@
position: relative; position: relative;
width: 100%; width: 100%;
} }
.not-found {
margin: 50px auto;
text-align: center;
}

View file

@ -6,7 +6,11 @@ import dayjs from 'dayjs';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { RouteComponentProps, withRouter } from 'react-router-dom'; import { RouteComponentProps, withRouter } from 'react-router-dom';
import PageErrorHandlingWrapper from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper'; import PageErrorHandlingWrapper, { PageBaseState } from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper';
import {
getWrongTeamResponseInfo,
initErrorDataState,
} from 'components/PageErrorHandlingWrapper/PageErrorHandlingWrapper.helpers';
import PluginLink from 'components/PluginLink/PluginLink'; import PluginLink from 'components/PluginLink/PluginLink';
import ScheduleWarning from 'components/ScheduleWarning/ScheduleWarning'; import ScheduleWarning from 'components/ScheduleWarning/ScheduleWarning';
import Text from 'components/Text/Text'; import Text from 'components/Text/Text';
@ -33,7 +37,7 @@ const cx = cn.bind(styles);
interface SchedulePageProps extends PageProps, WithStoreProps, RouteComponentProps<{ id: string }> {} interface SchedulePageProps extends PageProps, WithStoreProps, RouteComponentProps<{ id: string }> {}
interface SchedulePageState { interface SchedulePageState extends PageBaseState {
startMoment: dayjs.Dayjs; startMoment: dayjs.Dayjs;
schedulePeriodType: string; schedulePeriodType: string;
renderType: string; renderType: string;
@ -59,6 +63,7 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
isLoading: true, isLoading: true,
showEditForm: false, showEditForm: false,
showScheduleICalSettings: false, showScheduleICalSettings: false,
errorData: initErrorDataState(),
}; };
} }
@ -101,8 +106,11 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
shiftIdToShowOverridesForm, shiftIdToShowOverridesForm,
showEditForm, showEditForm,
showScheduleICalSettings, showScheduleICalSettings,
errorData,
} = this.state; } = this.state;
const { isNotFoundError } = errorData;
const { scheduleStore, currentTimezone } = store; const { scheduleStore, currentTimezone } = store;
const users = store.userStore.getSearchResult().results; const users = store.userStore.getSearchResult().results;
@ -115,131 +123,147 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
shiftIdToShowOverridesForm; shiftIdToShowOverridesForm;
return ( return (
<PageErrorHandlingWrapper pageName="schedules"> <PageErrorHandlingWrapper errorData={errorData} objectName="schedule" pageName="schedules">
{() => ( {() => (
<> <>
<div className={cx('root')}> <div className={cx('root')}>
<VerticalGroup spacing="lg"> {isNotFoundError ? (
<div className={cx('header')}> <div className={cx('not-found')}>
<HorizontalGroup justify="space-between"> <VerticalGroup spacing="lg" align="center">
<HorizontalGroup> <Text.Title level={1}>404</Text.Title>
<PluginLink query={{ page: 'schedules' }}> <Text.Title level={4}>Schedule not found</Text.Title>
<IconButton style={{ marginTop: '5px' }} name="arrow-left" size="xl" /> <PluginLink query={{ page: 'schedules' }}>
</PluginLink> <Button variant="secondary" icon="arrow-left" size="md">
<Text.Title Go to Schedules page
editable </Button>
editModalTitle="Schedule name" </PluginLink>
level={2} </VerticalGroup>
onTextChange={this.handleNameChange}
>
{schedule?.name}
</Text.Title>
{schedule && <ScheduleWarning item={schedule} />}
</HorizontalGroup>
<HorizontalGroup spacing="lg">
{users && (
<HorizontalGroup>
<Text type="secondary">Current timezone:</Text>
<UserTimezoneSelect
value={currentTimezone}
users={users}
onChange={this.handleTimezoneChange}
/>
</HorizontalGroup>
)}
<HorizontalGroup>
<HorizontalGroup>
<HorizontalGroup>
<Button variant="secondary" onClick={this.handleExportClick()}>
Export
</Button>
</HorizontalGroup>
{(schedule?.type === ScheduleType.Ical || schedule?.type === ScheduleType.Calendar) && (
<Button variant="secondary" onClick={this.handleReloadClick(scheduleId)}>
Reload
</Button>
)}
</HorizontalGroup>
<ToolbarButton
icon="cog"
tooltip="Settings"
onClick={() => {
this.setState({ showEditForm: true });
}}
/>
<WithConfirm>
<ToolbarButton icon="trash-alt" tooltip="Delete" onClick={this.handleDelete} />
</WithConfirm>
</HorizontalGroup>
</HorizontalGroup>
</HorizontalGroup>
</div> </div>
<div className={cx('users-timezones')}> ) : (
<UsersTimezones <VerticalGroup spacing="lg">
scheduleId={scheduleId} <div className={cx('header')}>
startMoment={startMoment}
onCallNow={schedule?.on_call_now || []}
userIds={
scheduleStore.relatedUsers[scheduleId] ? Object.keys(scheduleStore.relatedUsers[scheduleId]) : []
}
tz={currentTimezone}
onTzChange={this.handleTimezoneChange}
/>
</div>
<div className={cx('rotations')}>
<div className={cx('controls')}>
<HorizontalGroup justify="space-between"> <HorizontalGroup justify="space-between">
<HorizontalGroup> <HorizontalGroup>
<Button variant="secondary" onClick={this.handleTodayClick}> <PluginLink query={{ page: 'schedules' }}>
Today <IconButton style={{ marginTop: '5px' }} name="arrow-left" size="xl" />
</Button> </PluginLink>
<HorizontalGroup spacing="xs"> <Text.Title
<Button variant="secondary" onClick={this.handleLeftClick}> editable
<Icon name="angle-left" /> editModalTitle="Schedule name"
</Button> level={2}
<Button variant="secondary" onClick={this.handleRightClick}> onTextChange={this.handleNameChange}
<Icon name="angle-right" /> >
</Button> {schedule?.name}
</HorizontalGroup>
<Text.Title style={{ marginLeft: '8px' }} level={4} type="primary">
{startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')}
</Text.Title> </Text.Title>
{schedule && <ScheduleWarning item={schedule} />}
</HorizontalGroup>
<HorizontalGroup spacing="lg">
{users && (
<HorizontalGroup>
<Text type="secondary">Current timezone:</Text>
<UserTimezoneSelect
value={currentTimezone}
users={users}
onChange={this.handleTimezoneChange}
/>
</HorizontalGroup>
)}
<HorizontalGroup>
<HorizontalGroup>
<HorizontalGroup>
<Button variant="secondary" onClick={this.handleExportClick()}>
Export
</Button>
</HorizontalGroup>
{(schedule?.type === ScheduleType.Ical || schedule?.type === ScheduleType.Calendar) && (
<Button variant="secondary" onClick={this.handleReloadClick(scheduleId)}>
Reload
</Button>
)}
</HorizontalGroup>
<ToolbarButton
icon="cog"
tooltip="Settings"
onClick={() => {
this.setState({ showEditForm: true });
}}
/>
<WithConfirm>
<ToolbarButton icon="trash-alt" tooltip="Delete" onClick={this.handleDelete} />
</WithConfirm>
</HorizontalGroup>
</HorizontalGroup> </HorizontalGroup>
</HorizontalGroup> </HorizontalGroup>
</div> </div>
<ScheduleFinal <div className={cx('users-timezones')}>
scheduleId={scheduleId} <UsersTimezones
currentTimezone={currentTimezone} scheduleId={scheduleId}
startMoment={startMoment} startMoment={startMoment}
onClick={this.handleShowForm} onCallNow={schedule?.on_call_now || []}
disabled={disabled} userIds={
/> scheduleStore.relatedUsers[scheduleId]
<Rotations ? Object.keys(scheduleStore.relatedUsers[scheduleId])
scheduleId={scheduleId} : []
currentTimezone={currentTimezone} }
startMoment={startMoment} tz={currentTimezone}
onCreate={this.handleCreateRotation} onTzChange={this.handleTimezoneChange}
onUpdate={this.handleUpdateRotation} />
onDelete={this.handleDeleteRotation} </div>
shiftIdToShowRotationForm={shiftIdToShowRotationForm}
onShowRotationForm={this.handleShowRotationForm} <div className={cx('rotations')}>
disabled={disabled} <div className={cx('controls')}>
/> <HorizontalGroup justify="space-between">
<ScheduleOverrides <HorizontalGroup>
scheduleId={scheduleId} <Button variant="secondary" onClick={this.handleTodayClick}>
currentTimezone={currentTimezone} Today
startMoment={startMoment} </Button>
onCreate={this.handleCreateOverride} <HorizontalGroup spacing="xs">
onUpdate={this.handleUpdateOverride} <Button variant="secondary" onClick={this.handleLeftClick}>
onDelete={this.handleDeleteOverride} <Icon name="angle-left" />
shiftIdToShowRotationForm={shiftIdToShowOverridesForm} </Button>
onShowRotationForm={this.handleShowOverridesForm} <Button variant="secondary" onClick={this.handleRightClick}>
disabled={disabled} <Icon name="angle-right" />
/> </Button>
</div> </HorizontalGroup>
</VerticalGroup> <Text.Title style={{ marginLeft: '8px' }} level={4} type="primary">
{startMoment.format('DD MMM')} - {startMoment.add(6, 'day').format('DD MMM')}
</Text.Title>
</HorizontalGroup>
</HorizontalGroup>
</div>
<ScheduleFinal
scheduleId={scheduleId}
currentTimezone={currentTimezone}
startMoment={startMoment}
onClick={this.handleShowForm}
disabled={disabled}
/>
<Rotations
scheduleId={scheduleId}
currentTimezone={currentTimezone}
startMoment={startMoment}
onCreate={this.handleCreateRotation}
onUpdate={this.handleUpdateRotation}
onDelete={this.handleDeleteRotation}
shiftIdToShowRotationForm={shiftIdToShowRotationForm}
onShowRotationForm={this.handleShowRotationForm}
disabled={disabled}
/>
<ScheduleOverrides
scheduleId={scheduleId}
currentTimezone={currentTimezone}
startMoment={startMoment}
onCreate={this.handleCreateOverride}
onUpdate={this.handleUpdateOverride}
onDelete={this.handleDeleteOverride}
shiftIdToShowRotationForm={shiftIdToShowOverridesForm}
onShowRotationForm={this.handleShowOverridesForm}
disabled={disabled}
/>
</div>
</VerticalGroup>
)}
</div> </div>
{showEditForm && schedule && ( {showEditForm && schedule && (
<ScheduleForm <ScheduleForm
@ -325,7 +349,9 @@ class SchedulePage extends React.Component<SchedulePageProps, SchedulePageState>
const { startMoment } = this.state; const { startMoment } = this.state;
store.scheduleStore.updateItem(scheduleId); // to refresh current oncall users store.scheduleStore
.updateItem(scheduleId) // to refresh current oncall users
.catch((error) => this.setState({ errorData: { ...getWrongTeamResponseInfo(error) } }));
store.scheduleStore.updateRelatedUsers(scheduleId); // to refresh related users store.scheduleStore.updateRelatedUsers(scheduleId); // to refresh related users
return Promise.all([ return Promise.all([

View file

@ -1,7 +1,6 @@
.schedule { .schedule {
position: relative; position: relative;
margin: 20px 0; margin: 20px 0;
max-width: calc(100vw - 104px);
} }
.title { .title {