@@ -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 || '');
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 |
@@ -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 |
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user