Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Diogo's hiring challenge #37

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions phone-test/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,24 @@
"date-fns": "^2.29.3",
"graphql": "^16.6.0",
"husky": "^8.0.2",
"jsonwebtoken": "^9.0.2",
"lint-staged": "^13.0.3",
"prettier": "^2.7.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.4.3",
"react-scripts": "5.0.1",
"styled-components": "^5.3.6",
"subscriptions-transport-ws": "^0.11.0",
"typescript": "^4.4.2",
"web-vitals": "^2.1.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"setup:pw":"yarn playwright install",
"test:e2e": "yarn setup:pw && playwright test ./e2e.spec.ts",
"eject": "react-scripts eject"
},
"eslintConfig": {
Expand Down Expand Up @@ -59,5 +63,10 @@
"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [
"prettier --write"
]
},
"devDependencies": {
"@playwright/test": "^1.45.1",
"cypress": "^13.13.0",
"playwright": "^1.45.1"
}
}
11 changes: 11 additions & 0 deletions phone-test/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineConfig } from '@playwright/test';

/**
* See https://playwright.dev/docs/test-configuration.
*/ export default defineConfig({
webServer: {
command: 'yarn start',
url: 'http://localhost:3000',
reuseExistingServer: true
}
});
38 changes: 9 additions & 29 deletions phone-test/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,42 +5,20 @@ import { CallDetailsPage } from './pages/CallDetails';
import { Tractor } from '@aircall/tractor';

import './App.css';
import { ProtectedLayout } from './components/routing/ProtectedLayout';
import { darkTheme } from './style/theme/darkTheme';
import { RouterProvider } from 'react-router-dom';
import { GlobalAppStyle } from './style/global';
import { ApolloClient, InMemoryCache, ApolloProvider, createHttpLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
import { AuthProvider } from './hooks/useAuth';
import { ProtectedRoute } from './components/routing/ProtectedRoute';

const httpLink = createHttpLink({
uri: 'https://frontend-test-api.aircall.dev/graphql'
});

const authLink = setContext((_, { headers }) => {
// get the authentication token from local storage if it exists
const accessToken = localStorage.getItem('access_token');
const parsedToken = accessToken ? JSON.parse(accessToken) : undefined;

// return the headers to the context so httpLink can read them
return {
headers: {
...headers,
authorization: accessToken ? `Bearer ${parsedToken}` : ''
}
};
});

const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache()
});
import { TokenProvider } from './hooks/useToken';
import ApolloProvider from './hooks/useApollo';

export const router = createBrowserRouter(
createRoutesFromElements(
<Route element={<AuthProvider />}>
<Route path="/login" element={<LoginPage />} />
<Route path="/calls" element={<ProtectedLayout />}>
<Route path="/calls" element={<ProtectedRoute />}>
<Route path="/calls" element={<CallsListPage />} />
<Route path="/calls/:callId" element={<CallDetailsPage />} />
</Route>
Expand All @@ -51,9 +29,11 @@ export const router = createBrowserRouter(
function App() {
return (
<Tractor injectStyle theme={darkTheme}>
<ApolloProvider client={client}>
<RouterProvider router={router} />
<GlobalAppStyle />
<ApolloProvider>
<TokenProvider>
<RouterProvider router={router} />
<GlobalAppStyle />
</TokenProvider>
</ApolloProvider>
</Tractor>
);
Expand Down
8 changes: 8 additions & 0 deletions phone-test/src/apollo/shared/token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Token } from '../../helpers/constants';

export const getAccessToken = () => {
const accessToken = localStorage.getItem(Token.ACCESS);
const parsedToken = accessToken ? JSON.parse(accessToken) : undefined;

return accessToken ? `Bearer ${parsedToken}` : '';
};
10 changes: 10 additions & 0 deletions phone-test/src/components/AppContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Flex } from '@aircall/tractor';
import { PropsWithChildren } from 'react';

const AppContainer = ({ children }: PropsWithChildren) => (
<Flex flex={1} justifyContent="center" w="500px" mx="auto">
{children}
</Flex>
);

export { AppContainer };
38 changes: 38 additions & 0 deletions phone-test/src/components/ArchiveButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Reference, StoreObject, useMutation } from '@apollo/client';
import { ARCHIVE_CALL } from '../gql/mutations/archive';
import { Button } from '@aircall/tractor';

const ArchiveButton = ({ callId, isArchived }: { callId: string; isArchived?: boolean }) => {
const [archiveCall] = useMutation(ARCHIVE_CALL, {
update(cache, { data: { archiveCall } }) {
cache.modify({
fields: {
calls(existingCallsRefs = [], { readField }) {
return existingCallsRefs.map((callRef: Reference | StoreObject | undefined) => {
if (readField('id', callRef) === archiveCall.id) {
return { ...callRef, is_archived: archiveCall.is_archived };
}
return callRef;
});
}
}
});
}
});

const handleArchive = () => {
archiveCall({ variables: { id: callId } });
};

return (
<Button
size="xSmall"
variant={isArchived ? 'instructive' : 'alternative'}
onClick={handleArchive}
>
{isArchived ? 'Archive' : 'Unarchive'}
</Button>
);
};

export { ArchiveButton };
40 changes: 29 additions & 11 deletions phone-test/src/components/routing/ProtectedLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,40 @@
import { Outlet, Link } from 'react-router-dom';
import { Box, Flex, Spacer, Grid } from '@aircall/tractor';
import { Flex, Spacer, Grid, Button } from '@aircall/tractor';
import logo from '../../logo.png';
import { PropsWithChildren } from 'react';
import { AppContainer } from '../AppContainer';
import { useAuth } from '../../hooks/useAuth';
import { Link } from 'react-router-dom';

export const ProtectedLayout = ({ children }: PropsWithChildren) => {
const { user, logout } = useAuth();

export const ProtectedLayout = () => {
return (
<Box minWidth="100vh" p={4}>
<Flex justifyContent="space-between" alignItems="center">
<Flex
display="flex"
flex={1}
flexDirection="column"
justifyContent="space-between"
alignItems="center"
minHeight={'100vh'}
minWidth={'100vw'}
p={4}
>
<Flex minWidth="100%" justifyContent="space-between" alignItems="center">
<Link to="/calls">
<img src={logo} alt="Aircall" width="32px" height="32px" />
</Link>
<Spacer space="m" alignItems="center">
<span>{`Welcome {username}!`}</span>
<Link to="/login">logout</Link>
<span>{`Welcome ${user?.username ?? ''}!`}</span>
<Button size="xSmall" variant="destructive" data-testid="logout" onClick={logout}>
Logout
</Button>
</Spacer>
</Flex>
<Grid w="500px" mx="auto" rowGap={2}>
<Outlet />
</Grid>
</Box>
<AppContainer>
<Grid flex={1} rowGap={2}>
{children}
</Grid>
</AppContainer>
</Flex>
);
};
17 changes: 14 additions & 3 deletions phone-test/src/components/routing/ProtectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
// TODO check that the user is authenticated before displaying the route
return <>{children}</>;
import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { isTokenExpired } from '../../helpers/isTokenExpired';
import { useToken } from '../../hooks/useToken';

const ProtectedRoute: React.FC = () => {
const { accessToken } = useToken();
if (isTokenExpired(accessToken)) {
return <Navigate to="/login" replace />;
}

return <Outlet />;
};

export { ProtectedRoute };
5 changes: 5 additions & 0 deletions phone-test/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const Config = {
BASE_URL: 'frontend-test-api.aircall.dev'
};

export default Config;
10 changes: 10 additions & 0 deletions phone-test/src/gql/mutations/archive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';

export const ARCHIVE_CALL = gql`
mutation archiveCall($id: ID!) {
archiveCall(id: $id) {
id
is_archived
}
}
`;
3 changes: 3 additions & 0 deletions phone-test/src/gql/mutations/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
export * from './login';
export * from './refresh';
export * from './archive';
export * from './subscription';
14 changes: 14 additions & 0 deletions phone-test/src/gql/mutations/refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';

export const REFRESH_TOKEN_V2 = gql`
mutation refreshTokenV2 {
refreshTokenV2 {
access_token
refresh_token
user {
id
username
}
}
}
`;
12 changes: 12 additions & 0 deletions phone-test/src/gql/mutations/subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
import { CALL_FIELDS } from '../fragments';

export const ON_UPDATED_CALL = gql`
${CALL_FIELDS}

subscription {
onUpdatedCall {
...CallFields
}
}
`;
4 changes: 4 additions & 0 deletions phone-test/src/helpers/constants/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum Token {
ACCESS = 'access_token',
REFRESH = 'refresh_token'
}
82 changes: 80 additions & 2 deletions phone-test/src/helpers/dates.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,81 @@
import { formatDate } from './dates';
import { formatDuration, formatDate, formatDateAsDay } from './dates';
import { format, parseISO } from 'date-fns';

describe('dates helpers', () => {});
const HELPER_FORMATS = {
DAY_WITH_TIME: 'LLL d - HH:mm',
DAY_WITH_YEAR: 'LLL d - yyyy'
};

const formatTestDate = ({
date,
dateFormat,
isISO
}: {
date: string;
dateFormat: string;
isISO: boolean;
}) => format(isISO ? parseISO(date) : new Date(date), dateFormat);

describe('dates helpers', () => {
describe('formatDuration', () => {
it('formats duration of more than an hour correctly', () => {
const duration = 3661; // 1 hour, 1 minute, and 1 second
const result = formatDuration(duration);
expect(result).toBe('01:01:01');
});

it('formats duration of less than an hour correctly', () => {
const duration = 601; // 10 minutes and 1 second
const result = formatDuration(duration);
expect(result).toBe('10:01');
});
});

describe('formatDate', () => {
it('formats a valid ISO date string correctly', () => {
const date = '2022-11-16T13:37:05.822Z';
const result = formatDate(date);
const expectedResult = formatTestDate({
date,
dateFormat: HELPER_FORMATS.DAY_WITH_TIME,
isISO: true
});
expect(result).toBe(expectedResult);
});

it('formats a non-ISO date string correctly', () => {
const date = 'Mon Mar 09 2020 13:33:55 GMT+0100';
const result = formatDate(date);
const expectedResult = formatTestDate({
date,
dateFormat: HELPER_FORMATS.DAY_WITH_TIME,
isISO: false
});
expect(result).toBe(expectedResult);
});
});

describe('formatDateAsDay', () => {
it('formats a valid ISO date string correctly', () => {
const date = '2022-11-16T13:37:05.822Z';
const result = formatDateAsDay(date);
const expectedResult = formatTestDate({
date,
dateFormat: HELPER_FORMATS.DAY_WITH_YEAR,
isISO: true
});
expect(result).toBe(expectedResult);
});

it('formats a non-ISO date string correctly', () => {
const date = 'Mon Mar 09 2020 13:33:55 GMT+0100';
const result = formatDateAsDay(date);
const expectedResult = formatTestDate({
date,
dateFormat: HELPER_FORMATS.DAY_WITH_YEAR,
isISO: false
});
expect(result).toBe(expectedResult);
});
});
});
4 changes: 4 additions & 0 deletions phone-test/src/helpers/dates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@ const getValidDate = (date: Date | string) => {
export const formatDate = (date: string) => {
return format(getValidDate(date), 'LLL d - HH:mm');
};

export const formatDateAsDay = (date: string) => {
return format(getValidDate(date), 'LLL d - yyyy');
};
Loading