rename to gcalendar

Signed-off-by: Alex Rybchenko <arybchenko@box.com>
This commit is contained in:
Alex Rybchenko
2022-02-25 13:26:05 +01:00
parent 992af7d68e
commit 9297424748
36 changed files with 20 additions and 20 deletions
+73
View File
@@ -0,0 +1,73 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { OAuthApi, createApiRef, FetchApi } from '@backstage/core-plugin-api';
import { GCalendar, GCalendarEvent } from '../components/CalendarCard/types';
import { ResponseError } from '@backstage/errors';
type Options = {
authApi: OAuthApi;
fetchApi: FetchApi;
};
export const gcalendarApiRef = createApiRef<GCalendarApiClient>({
id: 'plugin.gcalendar.service',
});
export class GCalendarApiClient {
private readonly authApi: OAuthApi;
private readonly fetchApi: FetchApi;
constructor(options: Options) {
this.authApi = options.authApi;
this.fetchApi = options.fetchApi;
}
private async get<T>(
path: string,
params: { [key in string]: any },
): Promise<T> {
const query = new URLSearchParams(params);
const url = new URL(
`${path}?${query.toString()}`,
'https://www.googleapis.com',
);
const token = await this.authApi.getAccessToken();
const response = await this.fetchApi.fetch(url.toString(), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
return response.json() as Promise<T>;
}
public async getCalendars(params?: any) {
return this.get<{ items: GCalendar[] }>(
'/calendar/v3/users/me/calendarList',
params,
);
}
public async getEvents(calendarId: string, params?: any) {
return this.get<{ items: GCalendarEvent[] }>(
`/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`,
params,
);
}
}
@@ -0,0 +1,53 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import { AttendeeChip } from './AttendeeChip';
import { EventAttendee, ResponseStatus } from './types';
describe('<AttendeeChip />', () => {
it('renders attendee email', async () => {
const email = 'test@test.com';
const user: EventAttendee = {
email,
responseStatus: ResponseStatus.needsAction,
};
const { queryByText } = await renderInTestApp(<AttendeeChip user={user} />);
expect(queryByText(email)).toBeInTheDocument();
});
it('renders accepted icon', async () => {
const email = 'test@test.com';
const user: EventAttendee = {
email,
responseStatus: ResponseStatus.accepted,
};
const { getByTestId } = await renderInTestApp(<AttendeeChip user={user} />);
expect(getByTestId('accepted-icon')).toBeInTheDocument();
});
it('renders declined icon', async () => {
const email = 'test@test.com';
const user: EventAttendee = {
email,
responseStatus: ResponseStatus.declined,
};
const { getByTestId } = await renderInTestApp(<AttendeeChip user={user} />);
expect(getByTestId('declined-icon')).toBeInTheDocument();
});
});
@@ -0,0 +1,87 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { BackstageTheme } from '@backstage/theme';
import { Badge, Chip, makeStyles } from '@material-ui/core';
import CancelIcon from '@material-ui/icons/Cancel';
import CheckIcon from '@material-ui/icons/CheckCircle';
import { EventAttendee, ResponseStatus } from './types';
const useStyles = makeStyles((theme: BackstageTheme) => {
const getIconColor = (responseStatus?: string) => {
if (!responseStatus) return theme.palette.primary.light;
return {
[ResponseStatus.accepted]: theme.palette.status.ok,
[ResponseStatus.declined]: theme.palette.status.error,
}[responseStatus];
};
return {
responseStatus: {
color: ({ responseStatus }: { responseStatus?: string }) =>
getIconColor(responseStatus),
},
badge: {
right: 10,
top: 5,
'& svg': {
height: 16,
width: 16,
background: '#fff',
},
},
};
});
const ResponseIcon = ({ responseStatus }: any) => {
if (responseStatus === ResponseStatus.accepted) {
return <CheckIcon data-testid="accepted-icon" />;
}
if (responseStatus === ResponseStatus.declined) {
return <CancelIcon data-testid="declined-icon" />;
}
return null;
};
type AttendeeChipProps = {
user: EventAttendee;
};
export const AttendeeChip = ({ user }: AttendeeChipProps) => {
const classes = useStyles({ responseStatus: user.responseStatus });
return (
<Badge
classes={{
root: classes.responseStatus,
badge: classes.badge,
}}
badgeContent={<ResponseIcon responseStatus={user.responseStatus} />}
>
<Chip
size="small"
variant="outlined"
label={user.email}
color="primary"
/>
</Badge>
);
};
@@ -0,0 +1,155 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { googleAuthApiRef, storageApiRef } from '@backstage/core-plugin-api';
import {
MockStorageApi,
TestApiProvider,
renderInTestApp,
} from '@backstage/test-utils';
import { CalendarCardContainer } from '.';
import { gcalendarApiRef, gcalendarPlugin } from '../..';
describe('<CalendarCard />', () => {
const primaryCalendar = {
id: 'test-1@test.com',
summary: 'test-1@test.com',
primary: true,
};
const nonPrimaryCalendar = {
id: 'test-2@test.com',
summary: 'test-2@test.com',
primary: false,
};
const calendarData = {
items: [primaryCalendar, nonPrimaryCalendar],
};
const eventData = {
items: [
{
id: '1',
summary: 'Test event',
},
{
id: '2',
summary: 'Test event',
},
],
};
const getMockedApi = (calendarMock = {}, eventMock = {}) => ({
getCalendars: jest.fn().mockResolvedValue(calendarMock),
getEvents: jest.fn().mockResolvedValue(eventMock),
});
const getAuthMockApi = (token = 'token') => ({
getAccessToken: jest.fn().mockResolvedValue(token),
});
const mockStorage = MockStorageApi.create();
it('should render "Sign in" button if user not signed in', async () => {
const rendered = await renderInTestApp(
<TestApiProvider
apis={[
[gcalendarApiRef, getMockedApi()],
[googleAuthApiRef, getAuthMockApi('')],
]}
>
<CalendarCardContainer />
</TestApiProvider>,
);
expect(rendered.queryByText('Sign in')).toBeInTheDocument();
});
it('should render empty card', async () => {
const rendered = await renderInTestApp(
<TestApiProvider
apis={[
[gcalendarApiRef, getMockedApi()],
[googleAuthApiRef, getAuthMockApi()],
]}
>
<CalendarCardContainer />
</TestApiProvider>,
);
expect(rendered.queryByText('No events')).toBeInTheDocument();
expect(rendered.queryByText('Go to Calendar')).toBeInTheDocument();
});
it('should select primary calendar by default', async () => {
const rendered = await renderInTestApp(
<TestApiProvider
apis={[
[gcalendarApiRef, getMockedApi(calendarData, eventData)],
[googleAuthApiRef, getAuthMockApi()],
]}
>
<CalendarCardContainer />
</TestApiProvider>,
);
expect(rendered.queryByText(primaryCalendar.summary)).toBeInTheDocument();
expect(
rendered.queryByText(nonPrimaryCalendar.summary),
).not.toBeInTheDocument();
});
it('should render calendar events', async () => {
const rendered = await renderInTestApp(
<TestApiProvider
apis={[
[gcalendarApiRef, getMockedApi(calendarData, eventData)],
[googleAuthApiRef, getAuthMockApi()],
]}
>
<CalendarCardContainer />
</TestApiProvider>,
);
expect(rendered.queryByText('No events')).not.toBeInTheDocument();
expect(rendered.queryAllByText('Test event')).toHaveLength(2);
});
it('should select stored calendar', async () => {
mockStorage
.forBucket(gcalendarPlugin.getId())
.set('google_calendars_selected', [nonPrimaryCalendar.id]);
const rendered = await renderInTestApp(
<TestApiProvider
apis={[
[gcalendarApiRef, getMockedApi(calendarData, eventData)],
[googleAuthApiRef, getAuthMockApi()],
[storageApiRef, mockStorage],
]}
>
<CalendarCardContainer />
</TestApiProvider>,
);
expect(
rendered.queryByText(nonPrimaryCalendar.summary),
).toBeInTheDocument();
expect(
rendered.queryByText(primaryCalendar.summary),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,146 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { sortBy } from 'lodash';
import { DateTime } from 'luxon';
import React, { useEffect, useMemo, useState } from 'react';
import { InfoCard, Progress } from '@backstage/core-components';
import { useAnalytics } from '@backstage/core-plugin-api';
import { Box, IconButton, Typography } from '@material-ui/core';
import PrevIcon from '@material-ui/icons/NavigateBefore';
import NextIcon from '@material-ui/icons/NavigateNext';
import {
useCalendarsQuery,
useEventsQuery,
useSignIn,
useStoredCalendars,
} from '../../hooks';
import calendarCardIcon from '../../icons/calendarIcon.svg';
import { CalendarEvent } from './CalendarEvent';
import { CalendarSelect } from './CalendarSelect';
import { SignInContent } from './SignInContent';
import { getStartDate } from './util';
export const CalendarCard = () => {
const [date, setDate] = useState(DateTime.now());
const analytics = useAnalytics();
const changeDay = (offset = 1) => {
setDate(prev => prev.plus({ day: offset }));
analytics.captureEvent('click', 'change date');
};
const { isSignedIn, isInitialized, signIn } = useSignIn();
useEffect(() => {
signIn(true);
}, [signIn]);
const { isLoading: isCalendarLoading, data } = useCalendarsQuery({
enabled: isSignedIn,
});
const calendars = useMemo(() => data?.items || [], [data]);
const primaryCalendarId = calendars.find(c => c.primary === true)?.id;
const defaultSelectedCalendars = primaryCalendarId ? [primaryCalendarId] : [];
const [storedCalendars, setStoredCalendars] = useStoredCalendars(
defaultSelectedCalendars,
);
const { events, isLoading: isEventLoading } = useEventsQuery({
calendars,
selectedCalendars: storedCalendars,
enabled: isSignedIn && calendars.length > 0,
timeMin: date.startOf('day').toISO(),
timeMax: date.endOf('day').toISO(),
timeZone: date.zoneName,
});
return (
<InfoCard
noPadding
title={
<Box display="flex" alignItems="center">
<Box height={24} width={24} mr={1}>
<img src={calendarCardIcon} alt="Google Calendar" />
</Box>
{isSignedIn ? (
<>
<IconButton onClick={() => changeDay(-1)} size="small">
<PrevIcon />
</IconButton>
<IconButton onClick={() => changeDay(1)} size="small">
<NextIcon />
</IconButton>
<Box mr={0.5} />
<Typography variant="h6">
{date.toLocaleString({
weekday: 'short',
month: 'short',
day: 'numeric',
})}
</Typography>
<Box flex={1} />
<CalendarSelect
calendars={calendars}
selectedCalendars={storedCalendars}
setSelectedCalendars={setStoredCalendars}
disabled={isCalendarLoading || !isSignedIn}
/>
</>
) : (
<Typography variant="h6">Agenda</Typography>
)}
</Box>
}
deepLink={{
link: 'https://calendar.google.com/',
title: 'Go to Calendar',
}}
>
<Box>
{(isCalendarLoading || isEventLoading || !isInitialized) && (
<Box pt={2} pb={2}>
<Progress variant="query" />
</Box>
)}
{!isSignedIn && isInitialized && (
<SignInContent handleAuthClick={() => signIn(false)} />
)}
{!isEventLoading && !isCalendarLoading && isSignedIn && (
<Box p={1} pb={0} maxHeight={602} overflow="auto">
{events.length === 0 && (
<Box pt={2} pb={2}>
<Typography align="center" variant="h6">
No events
</Typography>
</Box>
)}
{sortBy(events, [getStartDate]).map(event => (
<CalendarEvent
key={`${event.calendarId}-${event.id}`}
event={event}
/>
))}
</Box>
)}
</Box>
</InfoCard>
);
};
@@ -0,0 +1,29 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { CalendarCard } from './CalendarCard';
const queryClient = new QueryClient();
export const CalendarCardContainer = () => {
return (
<QueryClientProvider client={queryClient}>
<CalendarCard />
</QueryClientProvider>
);
};
@@ -0,0 +1,83 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { fireEvent } from '@testing-library/react';
import React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import { CalendarEvent } from './CalendarEvent';
describe('<CalendarEvent />', () => {
const event = {
summary: 'Test event',
htmlLink: '/calendar-link',
start: {
dateTime: '2022-02-09T10:15:00',
},
end: {
dateTime: '2022-02-09T10:45:00',
},
conferenceData: {
entryPoints: [{ entryPointType: 'video', uri: '/zoom-link' }],
},
description: 'Test description',
attendees: [
{
email: 'test@test.com',
},
],
};
it('should render calendar event', async () => {
const { queryByText, queryByTestId } = await renderInTestApp(
<CalendarEvent event={event} />,
);
expect(queryByText(event.summary)).toBeInTheDocument();
expect(queryByTestId('calendar-event-zoom-link')).toBeInTheDocument();
expect(queryByTestId('calendar-event-zoom-link')).toHaveAttribute(
'href',
event.conferenceData.entryPoints[0].uri,
);
expect(queryByTestId('calendar-event-time')).toBeInTheDocument();
});
it('should not render time for events longer than 1 day', async () => {
const allDayEvent = {
summary: 'Test event',
start: {
date: '2022-02-09',
},
end: {
date: '2022-02-19',
},
};
const { queryByText, queryByTestId } = await renderInTestApp(
<CalendarEvent event={allDayEvent} />,
);
expect(queryByText(allDayEvent.summary)).toBeInTheDocument();
expect(queryByTestId('calendar-event-time')).not.toBeInTheDocument();
});
it('should show popover on click', async () => {
const { queryByTestId, getByTestId } = await renderInTestApp(
<CalendarEvent event={event} />,
);
expect(queryByTestId('calendar-event-popover')).not.toBeInTheDocument();
fireEvent.click(getByTestId('calendar-event'));
expect(queryByTestId('calendar-event-popover')).toBeInTheDocument();
});
});
@@ -0,0 +1,160 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import classnames from 'classnames';
import {
bindPopover,
bindTrigger,
usePopupState,
} from 'material-ui-popup-state/hooks';
import React, { useState } from 'react';
import { useAnalytics } from '@backstage/core-plugin-api';
import {
Box,
Link,
Paper,
Popover,
Tooltip,
Typography,
makeStyles,
} from '@material-ui/core';
import zoomIcon from '../../icons/zoomIcon.svg';
import { CalendarEventPopoverContent } from './CalendarEventPopoverContent';
import { GCalendarEvent, ResponseStatus } from './types';
import { getTimePeriod, getZoomLink, isAllDay, isPassed } from './util';
const useStyles = makeStyles(theme => ({
event: {
display: 'flex',
alignItems: 'center',
marginBottom: theme.spacing(1),
cursor: 'pointer',
paddingRight: 12,
},
declined: {
textDecoration: 'line-through',
},
passed: {
opacity: 0.6,
transition: 'opacity 0.15s ease-in-out',
'&:hover': {
opacity: 1,
},
},
link: {
width: 48,
height: 48,
display: 'inline-block',
padding: 8,
borderRadius: '50%',
'&:hover': {
backgroundColor: theme.palette.grey[100],
},
},
calendarColor: ({ event }: any) => ({
width: 8,
borderTopLeftRadius: 4,
borderBottomLeftRadius: 4,
backgroundColor: event.primary
? theme.palette.primary.light
: event.backgroundColor,
}),
}));
export const CalendarEvent = ({ event }: { event: GCalendarEvent }) => {
const classes = useStyles({ event });
const popoverState = usePopupState({
variant: 'popover',
popupId: event.id,
disableAutoFocus: true,
});
const [hovered, setHovered] = useState(false);
const analytics = useAnalytics();
const zoomLink = getZoomLink(event);
const { onClick, ...restBindProps } = bindTrigger(popoverState);
return (
<>
<Paper
onClick={e => {
onClick(e);
analytics.captureEvent('click', 'event info');
}}
{...restBindProps}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
elevation={hovered ? 4 : 1}
className={classnames(classes.event, {
[classes.passed]: isPassed(event),
})}
data-testid="calendar-event"
>
<Box className={classes.calendarColor} mr={1} alignSelf="stretch" />
<Box flex={1} pt={1} pb={1}>
<Typography
variant="subtitle2"
className={classnames({
[classes.declined]:
event.responseStatus === ResponseStatus.declined,
})}
>
{event.summary}
</Typography>
{!isAllDay(event) && (
<Typography variant="body2" data-testid="calendar-event-time">
{getTimePeriod(event)}
</Typography>
)}
</Box>
{zoomLink && (
<Tooltip title="Join Zoom Meeting">
<Link
data-testid="calendar-event-zoom-link"
className={classes.link}
href={zoomLink}
target="_blank"
onClick={e => {
e.stopPropagation();
analytics.captureEvent('click', 'zoom link');
}}
>
<img src={zoomIcon} alt="Zoom link" />
</Link>
</Tooltip>
)}
</Paper>
<Popover
{...bindPopover(popoverState)}
anchorOrigin={{
vertical: 'top',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
data-testid="calendar-event-popover"
>
<CalendarEventPopoverContent event={event} />
</Popover>
</>
);
};
@@ -0,0 +1,55 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import { CalendarEventPopoverContent } from './CalendarEventPopoverContent';
describe('<CalendarEventPopoverContent />', () => {
const event = {
summary: 'Test event',
htmlLink: '/calendar-link',
conferenceData: {
entryPoints: [{ entryPointType: 'video', uri: '/zoom-link' }],
},
description: 'Test description',
attendees: [
{
email: 'test@test.com',
},
],
};
it('should render event info', async () => {
const { queryByText, queryByTestId } = await renderInTestApp(
<CalendarEventPopoverContent event={event} />,
);
expect(queryByText(event.summary)).toBeInTheDocument();
expect(queryByText(event.description)).toBeInTheDocument();
expect(queryByText(event.attendees[0].email)).toBeInTheDocument();
expect(queryByText('Join Zoom Meeting')).toBeInTheDocument();
expect(queryByText('Join Zoom Meeting')?.closest('a')).toHaveAttribute(
'href',
event.conferenceData.entryPoints[0].uri,
);
expect(queryByTestId('open-calendar-link')).toHaveAttribute(
'href',
event.htmlLink,
);
expect(queryByText(event.attendees[0].email)).toBeInTheDocument();
});
});
@@ -0,0 +1,126 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { sortBy } from 'lodash';
import React from 'react';
import DOMPurify from 'dompurify';
import { useAnalytics } from '@backstage/core-plugin-api';
import {
Box,
Divider,
IconButton,
Link,
Tooltip,
Typography,
makeStyles,
} from '@material-ui/core';
import ArrowForwardIcon from '@material-ui/icons/ArrowForward';
import { AttendeeChip } from './AttendeeChip';
import { GCalendarEvent } from './types';
import { getTimePeriod, getZoomLink } from './util';
const useStyles = makeStyles(theme => {
return {
description: {
wordBreak: 'break-word',
'& a': {
color: theme.palette.primary.main,
fontWeight: 500,
},
},
divider: {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
},
};
});
type CalendarEventPopoverProps = {
event: GCalendarEvent;
};
export const CalendarEventPopoverContent = ({
event,
}: CalendarEventPopoverProps) => {
const classes = useStyles({ event });
const analytics = useAnalytics();
const zoomLink = getZoomLink(event);
return (
<Box display="flex" flexDirection="column" width={400} p={2}>
<Box display="flex" alignItems="center">
<Box flex={1}>
<Typography variant="h6">{event.summary}</Typography>
<Typography variant="subtitle2">{getTimePeriod(event)}</Typography>
</Box>
{event.htmlLink && (
<Tooltip title="Open in Calendar">
<Link
data-testid="open-calendar-link"
href={event.htmlLink}
target="_blank"
onClick={_e =>
analytics.captureEvent('click', 'open in calendar')
}
>
<IconButton>
<ArrowForwardIcon />
</IconButton>
</Link>
</Tooltip>
)}
</Box>
{zoomLink && (
<Link
href={zoomLink}
target="_blank"
onClick={_e => analytics.captureEvent('click', 'zoom link')}
>
Join Zoom Meeting
</Link>
)}
{event.description && (
<>
<Divider className={classes.divider} variant="fullWidth" />
<Box
className={classes.description}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(event.description, {
USE_PROFILES: { html: true },
}),
}}
/>
</>
)}
{event.attendees && (
<>
<Divider className={classes.divider} variant="fullWidth" />
<Box>
<Typography variant="subtitle2">Attendees</Typography>
<Box mb={1} />
{sortBy(event.attendees || [], 'email').map(user => (
<AttendeeChip key={user.email} user={user} />
))}
</Box>
</>
)}
</Box>
);
};
@@ -0,0 +1,91 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { sortBy } from 'lodash';
import React from 'react';
import {
Checkbox,
FormControl,
Input,
ListItemText,
MenuItem,
Select,
Typography,
makeStyles,
} from '@material-ui/core';
import { GCalendar } from './types';
const useStyles = makeStyles({
formControl: {
width: 120,
},
selectedCalendars: {
textOverflow: 'ellipsis',
overflow: 'hidden',
},
});
type CalendarSelectProps = {
disabled: boolean;
selectedCalendars?: string[];
setSelectedCalendars: (value: string[]) => void;
calendars: GCalendar[];
};
export const CalendarSelect = ({
disabled,
selectedCalendars = [],
setSelectedCalendars,
calendars,
}: CalendarSelectProps) => {
const classes = useStyles();
return (
<FormControl className={classes.formControl}>
<Select
labelId="calendars-label"
disabled={disabled || calendars.length === 0}
multiple
value={selectedCalendars}
onChange={async e => setSelectedCalendars(e.target.value as string[])}
input={<Input />}
renderValue={selected => (
<Typography className={classes.selectedCalendars} variant="body2">
{calendars
.filter(c => c.id && (selected as string[]).includes(c.id))
.map(c => c.summary)
.join(', ')}
</Typography>
)}
MenuProps={{
PaperProps: {
style: {
width: 350,
},
},
}}
>
{sortBy(calendars, 'summary').map(c => (
<MenuItem key={c.id} value={c.id}>
<Checkbox checked={selectedCalendars.includes(c.id!)} />
<ListItemText primary={c.summary} />
</MenuItem>
))}
</Select>
</FormControl>
);
};
@@ -0,0 +1,62 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Box, Button, styled } from '@material-ui/core';
import { CalendarEvent } from './CalendarEvent';
import { events } from './signInEventMock';
type SignInContentProps = {
handleAuthClick: React.MouseEventHandler<HTMLElement>;
};
const TransparentBox = styled(Box)({
opacity: 0.3,
filter: 'blur(1.5px)',
});
export const SignInContent = ({ handleAuthClick }: SignInContentProps) => {
return (
<Box position="relative" height="100%" width="100%">
<TransparentBox p={1}>
{events.map(event => (
<CalendarEvent key={event.id} event={event} />
))}
</TransparentBox>
<Box
height="100%"
width="100%"
display="flex"
justifyContent="center"
alignItems="center"
position="absolute"
left={0}
top={0}
>
<Button
variant="contained"
color="primary"
onClick={handleAuthClick}
size="large"
>
Sign in
</Button>
</Box>
</Box>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { CalendarCardContainer } from './CalendarCardContainer';
@@ -0,0 +1,117 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { GCalendarEvent } from './types';
export const events: GCalendarEvent[] = [
{
id: '1',
htmlLink: 'https://www.google.com/calendar/',
summary: 'Backstage Community Sessions',
start: {
dateTime: '2021-12-07T09:00:00+01:00',
timeZone: 'Europe/London',
},
end: {
dateTime: '2021-12-07T10:00:00+01:00',
timeZone: 'Europe/London',
},
},
{
id: '2',
htmlLink: 'https://www.google.com/calendar/',
summary: 'Backstage Community Sessions',
start: {
dateTime: '2021-12-07T10:30:00+01:00',
timeZone: 'Europe/London',
},
end: {
dateTime: '2021-12-07T10:45:00+01:00',
timeZone: 'Europe/London',
},
conferenceData: {
entryPoints: [
{
entryPointType: 'video',
uri: 'https://zoom.us',
},
],
},
},
{
id: '3',
htmlLink: 'https://www.google.com/calendar',
summary: 'Backstage Community Sessions',
start: {
dateTime: '2021-12-07T12:00:00+01:00',
timeZone: 'Europe/London',
},
end: {
dateTime: '2021-12-07T13:00:00+01:00',
timeZone: 'Europe/London',
},
conferenceData: {
entryPoints: [
{
entryPointType: 'video',
uri: 'https://zoom.us',
label: 'zoom.us',
},
],
},
},
{
id: '4',
htmlLink: 'https://www.google.com/calendar',
summary: 'Backstage Community Sessions',
start: {
dateTime: '2021-12-07T15:00:00+01:00',
timeZone: 'Europe/London',
},
end: {
dateTime: '2021-12-07T16:30:00+01:00',
timeZone: 'Europe/London',
},
conferenceData: {
entryPoints: [
{
entryPointType: 'video',
uri: 'https://zoom.us',
},
],
},
},
{
id: '5',
htmlLink: 'https://www.google.com/calendar',
summary: 'Backstage Community Sessions',
start: {
dateTime: '2021-12-07T17:00:00+01:00',
timeZone: 'Europe/London',
},
end: {
dateTime: '2021-12-07T17:30:00+01:00',
timeZone: 'Europe/London',
},
conferenceData: {
entryPoints: [
{
entryPointType: 'video',
uri: 'https://zoom.us',
},
],
},
},
];
@@ -0,0 +1,35 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference types="gapi.auth2" />
/// <reference types="gapi.client.calendar" />
export type GCalendar = gapi.client.calendar.CalendarListEntry;
export type EventAttendee = gapi.client.calendar.EventAttendee;
export type GCalendarEvent = gapi.client.calendar.Event &
Pick<GCalendar, 'backgroundColor' | 'primary'> &
Pick<EventAttendee, 'responseStatus'> & {
calendarId?: string;
};
export enum ResponseStatus {
needsAction = 'needsAction',
accepted = 'accepted',
declined = 'declined',
maybe = 'tentative',
}
@@ -0,0 +1,82 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DateTime } from 'luxon';
import { GCalendarEvent } from './types';
export function getZoomLink(event: GCalendarEvent) {
const videoEntrypoint = event.conferenceData?.entryPoints?.find(
e => e.entryPointType === 'video',
);
return videoEntrypoint?.uri ?? '';
}
export function getTimePeriod(event: GCalendarEvent) {
if (isAllDay(event)) {
return getAllDayTimePeriod(event);
}
const format: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
};
const startTime = DateTime.fromISO(event.start?.dateTime || '');
const endTime = DateTime.fromISO(event.end?.dateTime || '');
return `${startTime.toLocaleString(format)} - ${endTime.toLocaleString(
format,
)}`;
}
function getAllDayTimePeriod(event: GCalendarEvent) {
const format: Intl.DateTimeFormatOptions = { month: 'long', day: 'numeric' };
const startTime = DateTime.fromISO(
event.start?.dateTime || event.start?.date || '',
);
const endTime = DateTime.fromISO(
event.end?.dateTime || event.end?.date || '',
).minus({ day: 1 });
if (startTime.toISO() === endTime.toISO()) {
return startTime.toLocaleString(format);
}
return `${startTime.toLocaleString(format)} - ${endTime.toLocaleString(
format,
)}`;
}
export function isPassed(event: GCalendarEvent) {
if (!event.end?.dateTime && !event.end?.date) return false;
const eventDate = DateTime.fromISO(event.end?.dateTime || event.end?.date!);
return DateTime.now() >= eventDate;
}
export function isAllDay(event: GCalendarEvent) {
if (event.start?.date || event.end?.date) {
return true;
}
const startTime = DateTime.fromISO(event.start?.dateTime || '');
const endTime = DateTime.fromISO(event.end?.dateTime || '');
return endTime.diff(startTime, 'day').days >= 1;
}
export function getStartDate(event: GCalendarEvent) {
return DateTime.fromISO(event.start?.dateTime || event.start?.date || '');
}
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { useCalendarsQuery } from './useCalendarsQuery';
export { useEventsQuery } from './useEventsQuery';
export { useSignIn } from './useSignIn';
export { useStoredCalendars } from './useStoredCalendars';
@@ -0,0 +1,49 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useQuery } from 'react-query';
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
import { gcalendarApiRef } from '../api';
type Options = {
enabled: boolean;
refreshTime?: number;
};
export const useCalendarsQuery = ({ enabled }: Options) => {
const calendarApi = useApi(gcalendarApiRef);
const errorApi = useApi(errorApiRef);
return useQuery(
['calendars'],
async () =>
calendarApi.getCalendars({
minAccessRole: 'reader',
}),
{
enabled,
keepPreviousData: true,
refetchInterval: 3600000,
onError: () => {
errorApi.post({
name: 'API error',
message: 'Failed to fetch calendars.',
});
},
},
);
};
@@ -0,0 +1,96 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { compact, unescape } from 'lodash';
import { useMemo } from 'react';
import { useQueries } from 'react-query';
import { useApi } from '@backstage/core-plugin-api';
import { gcalendarApiRef } from '../api';
import { GCalendar, GCalendarEvent } from '../components/CalendarCard/types';
type Options = {
selectedCalendars?: string[];
timeMin: string;
timeMax: string;
enabled: boolean;
calendars: GCalendar[];
timeZone: string;
refreshTime?: number;
};
export const useEventsQuery = ({
selectedCalendars = [],
calendars = [],
enabled,
timeMin,
timeMax,
timeZone,
}: Options) => {
const calendarApi = useApi(gcalendarApiRef);
const eventQueries = useQueries(
selectedCalendars
.filter(id => calendars.find(c => c.id === id))
.map(calendarId => {
const calendar = calendars.find(c => c.id === calendarId);
return {
queryKey: ['calendarEvents', calendarId, timeMin, timeMax],
enabled,
initialData: [],
refetchInterval: 60000,
refetchIntervalInBackground: true,
queryFn: async (): Promise<GCalendarEvent[]> => {
const data = await calendarApi.getEvents(calendarId, {
calendarId,
timeMin,
timeMax,
showDeleted: false,
singleEvents: true,
maxResults: 100,
orderBy: 'startTime',
timeZone,
});
return (data.items || []).map(event => {
const responseStatus = event.attendees?.find(
a => !!a.self,
)?.responseStatus;
return {
...event,
summary: unescape(event.summary || ''),
calendarId,
backgroundColor: calendar?.backgroundColor,
primary: !!calendar?.primary,
responseStatus,
};
});
},
};
}),
);
const events = useMemo(
() => compact(eventQueries.map(({ data }) => data).flat()),
[eventQueries],
);
const isLoading =
!!eventQueries.find(q => q.isFetching) && events.length === 0;
return { events, isLoading };
};
+42
View File
@@ -0,0 +1,42 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useCallback, useState } from 'react';
import { googleAuthApiRef, useApi } from '@backstage/core-plugin-api';
export const useSignIn = () => {
const [isSignedIn, setSignedIn] = useState(false);
const [isInitialized, setInitialized] = useState(false);
const authApi = useApi(googleAuthApiRef);
const signIn = useCallback(
async (optional = false) => {
const token = await authApi.getAccessToken(
'https://www.googleapis.com/auth/calendar.readonly',
{
optional,
instantPopup: !optional,
},
);
setSignedIn(!!token);
setInitialized(true);
},
[authApi, setSignedIn],
);
return { isSignedIn, isInitialized, signIn };
};
@@ -0,0 +1,45 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useApi, storageApiRef } from '@backstage/core-plugin-api';
import { useObservable } from 'react-use';
import { gcalendarPlugin } from '../plugin';
export enum LocalStorageKeys {
selectedCalendars = 'google_calendars_selected',
}
export function useStoredCalendars(
defaultValue: string[],
): [string[], (value: string[]) => void] {
const storageBucket = gcalendarPlugin.getId();
const storageKey = LocalStorageKeys.selectedCalendars;
const storageApi = useApi(storageApiRef).forBucket(storageBucket);
const setValue = (value: string[]) => {
storageApi.set(storageKey, value);
};
const snapshot = useObservable(
storageApi.observe$<string[]>(storageKey),
storageApi.snapshot(storageKey),
);
let result: string[];
if (snapshot.presence === 'absent') {
result = defaultValue;
} else {
result = snapshot.value!;
}
return [result, setValue];
}
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="186 38 76 76">
<path fill="#fff" d="M244 56h-40v40h40V56z" />
<path fill="#EA4335" d="M244 114l18-18h-18v18z" />
<path fill="#FBBC04" d="M262 56h-18v40h18V56z" />
<path fill="#34A853" d="M244 96h-40v18h40V96z" />
<path fill="#188038" d="M186 96v12c0 3.315 2.685 6 6 6h12V96h-18z" />
<path fill="#1967D2" d="M262 56V44c0-3.315-2.685-6-6-6h-12v18h18z" />
<path fill="#4285F4" d="M244 38h-52c-3.315 0 -6 2.685-6 6v52h18V56h40V38z" />
<path fill="#4285F4" d="M212.205 87.03c-1.495-1.01-2.53-2.485-3.095-4.435l3.47-1.43c.315 1.2.865 2.13 1.65 2.79.78.66 1.73.985 2.84.985 1.135 0 2.11-.345 2.925-1.035s1.225-1.57 1.225-2.635c0-1.09-.43-1.98-1.29-2.67-.86-.69-1.94-1.035-3.23-1.035h-2.005V74.13h1.8c1.11 0 2.045-.3 2.805-.9.76-.6 1.14-1.42 1.14-2.465 0 -.93-.34-1.67-1.02-2.225-.68-.555-1.54-.835-2.585-.835-1.02 0 -1.83.27-2.43.815a4.784 4.784 0 0 0 -1.31 2.005l-3.435-1.43c.455-1.29 1.29-2.43 2.515-3.415 1.225-.985 2.79-1.48 4.69-1.48 1.405 0 2.67.27 3.79.815 1.12.545 2 1.3 2.635 2.26.635.965.95 2.045.95 3.245 0 1.225-.295 2.26-.885 3.11-.59.85-1.315 1.5-2.175 1.955v.205a6.605 6.605 0 0 1 2.79 2.175c.725.975 1.09 2.14 1.09 3.5 0 1.36-.345 2.575-1.035 3.64s-1.645 1.905-2.855 2.515c-1.215.61-2.58.92-4.095.92-1.755.005-3.375-.5-4.87-1.51zM233.52 69.81l-3.81 2.755-1.905-2.89 6.835-4.93h2.62V88h-3.74V69.81z" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="20" fill="#2196f3" />
<path fill="#fff" d="M29,31H14c-1.657,0-3-1.343-3-3V17h15c1.657,0,3,1.343,3,3V31z" />
<polygon fill="#fff" points="37,31 31,27 31,21 37,17" />
</svg>

After

Width:  |  Height:  |  Size: 265 B

+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { gcalendarPlugin, CalendarCard } from './plugin';
export * from './api';
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { gcalendarPlugin } from './plugin';
describe('gcalendar-homepage', () => {
it('should export plugin', () => {
expect(gcalendarPlugin).toBeDefined();
});
});
+51
View File
@@ -0,0 +1,51 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createApiFactory,
createComponentExtension,
createPlugin,
fetchApiRef,
googleAuthApiRef,
} from '@backstage/core-plugin-api';
import { GCalendarApiClient, gcalendarApiRef } from './api';
import { rootRouteRef } from './routes';
export const gcalendarPlugin = createPlugin({
id: 'gcalendar-homepage',
routes: {
root: rootRouteRef,
},
apis: [
createApiFactory({
api: gcalendarApiRef,
deps: { authApi: googleAuthApiRef, fetchApi: fetchApiRef },
factory(deps) {
return new GCalendarApiClient(deps);
},
}),
],
});
export const CalendarCard = gcalendarPlugin.provide(
createComponentExtension({
name: 'CalendarCard',
component: {
lazy: () =>
import('./components/CalendarCard').then(m => m.CalendarCardContainer),
},
}),
);
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
id: 'gcalendar-homepage',
});
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@testing-library/jest-dom';
import 'cross-fetch/polyfill';