Initial commit AB#12

This commit is contained in:
Noah Spannbauer
2023-01-24 20:03:13 -06:00
commit c2d8bd1838
91 changed files with 29613 additions and 0 deletions

22
app/.babelrc Normal file
View File

@@ -0,0 +1,22 @@
{
"presets": [
"@babel/preset-env",
[
"@babel/preset-react",
{
"runtime": "automatic"
}
],
"@babel/preset-typescript"
],
"plugins": [
[
"@babel/plugin-transform-runtime",
{
"regenerator": true
},
],
"istanbul"
],
}

2
app/.eslintignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
cypress

14
app/.eslintrc Normal file
View File

@@ -0,0 +1,14 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint",
"prettier"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
]
}

4
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
.nyc_output
coverage-cy
dist

8
app/.nycrc Normal file
View File

@@ -0,0 +1,8 @@
{
"all": true,
"excludeAfterRemap": true,
"report-dir": "coverage-cy",
"reporter": ["text", "json", "html"],
"extension": [".js"],
"include": "src/**/*.js"
}

4
app/.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
.eslintrc
package-lock.json
package.json
tsconfig.json

5
app/.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"jsxSingleQuote": true,
"singleQuote": true,
"trailingComma": "none"
}

0
app/README.md Normal file
View File

69
app/cypress.config.ts Normal file
View File

@@ -0,0 +1,69 @@
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
setupNodeEvents(on, config) {
// return Object.assign({}, config, require('@bahmutov/cypress-code-coverage/plugin')(on, config));
require('@bahmutov/cypress-code-coverage/plugin')(on, config);
return config;
},
// baseUrl: 'http://localhost:3000',
// specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}'
},
component: {
devServer: {
framework: "react",
bundler: "webpack",
// here are the additional settings from Gleb's instructions
webpackConfig: {
mode: "development",
devtool: false,
module: {
rules: [
// application and Cypress files are bundled like React components
// and instrumented using the babel-plugin-istanbul
{
test: /\.(ts|js)x?$/,
exclude: /node_modules/,
resolve: {
extensions: ['.ts', '.tsx', '.js', '.json'],
},
use: {
loader: "babel-loader",
options: {
presets: [
"@babel/preset-env",
"@babel/preset-react",
'@babel/preset-typescript'
],
plugins: [
[
"@babel/plugin-transform-runtime",
{
"regenerator": true
},
],
"istanbul"
],
},
},
},
{
test: /\.(s(a|c)ss)$/,
use: ['css-loader', 'sass-loader'],
}
],
},
},
},
setupNodeEvents(on, config) {
// return Object.assign({}, config, require('@bahmutov/cypress-code-coverage/plugin')(on, config))
require('@bahmutov/cypress-code-coverage/plugin')(on, config)
return config
},
specPattern: 'src/**/**/*.cy.{js,jsx,ts,tsx}'
},
});

View File

@@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -0,0 +1,37 @@
/// <reference types="cypress" />
// ***********************************************
// This example commands.ts shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
// declare global {
// namespace Cypress {
// interface Chainable {
// login(email: string, password: string): Chainable<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Components App</title>
</head>
<body>
<div data-cy-root></div>
</body>
</html>

View File

@@ -0,0 +1,41 @@
// ***********************************************************
// This example support/component.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
// import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')
import { mount } from 'cypress/react18'
import '@bahmutov/cypress-code-coverage/support';
// Augment the Cypress namespace to include type definitions for
// your custom command.
// Alternatively, can be defined in cypress/support/component.d.ts
// with a <reference path="./component" /> at the top of your spec.
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount
}
}
}
Cypress.Commands.add('mount', mount)
// Example use:
// cy.mount(<MyComponent />)

View File

@@ -0,0 +1 @@
import '@bahmutov/cypress-code-coverage/support';

Binary file not shown.

1
app/declaration.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module '*.scss';

12
app/index.html Normal file
View File

@@ -0,0 +1,12 @@
<html>
<head lang="en">
<title>MLB Gameday</title>
<style>
body {
margin: 0;
}
</style>
</html>
<body>
<div id="app-root"></div>
</body>

26331
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

74
app/package.json Normal file
View File

@@ -0,0 +1,74 @@
{
"name": "mlb-gameday",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "webpack serve --port 3000",
"build": "NODE_ENV=production webpack",
"cy:run": "npx cypress run --component",
"test": "start-server-and-test start http://localhost:3000 cy:run",
"lint": "eslint ./src --fix",
"format": "prettier --write ./src"
},
"repository": {
"type": "git",
"url": "git+https://github.com/noahspannbauer/react-mlb-schedule-js.git"
},
"author": "Noah Spannbauer",
"license": "ISC",
"bugs": {
"url": "https://github.com/noahspannbauer/react-mlb-schedule-js/issues"
},
"homepage": "https://github.com/noahspannbauer/react-mlb-schedule-js#readme",
"simple-git-hooks": {
"pre-commit": "pretty-quick --staged && npx lint-staged"
},
"devDependencies": {
"@babel/core": "^7.20.12",
"@babel/plugin-transform-runtime": "^7.19.6",
"@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.18.6",
"@babel/runtime": "^7.20.13",
"@bahmutov/cypress-code-coverage": "^1.3.2",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@typescript-eslint/eslint-plugin": "^5.48.2",
"@typescript-eslint/parser": "^5.48.2",
"babel-loader": "^9.1.2",
"babel-plugin-istanbul": "^6.1.1",
"css-loader": "^6.7.3",
"cypress": "^12.3.0",
"eslint": "^8.32.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-prettier": "^4.2.1",
"html-webpack-plugin": "^5.5.0",
"istanbul-lib-coverage": "^3.2.0",
"lint-staged": "^13.1.0",
"mini-css-extract-plugin": "^2.7.2",
"node-sass": "^8.0.0",
"nyc": "^15.1.0",
"prettier": "^2.8.3",
"pretty-quick": "^3.1.3",
"sass-loader": "^13.2.0",
"simple-git-hooks": "^2.8.1",
"start-server-and-test": "^1.15.3",
"ts-loader": "^9.4.2",
"ts-node": "^10.9.1",
"typescript": "^4.9.4",
"webpack": "^5.75.0",
"webpack-cli": "^5.0.1",
"webpack-dev-server": "^4.11.1"
},
"dependencies": {
"@apollo/client": "^3.7.3",
"@fluentui/react": "^8.104.2",
"classnames": "^2.3.2",
"graphql": "^16.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-grid-system": "^8.1.6",
"react-router-dom": "^6.6.1"
}
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { Routes, Route, useLocation } from 'react-router-dom';
import { Container, Row, Col } from 'react-grid-system';
import { initializeIcons } from '@fluentui/font-icons-mdl2';
import Menu from '../menu/Menu';
import Venues from '../venues/Venues';
initializeIcons();
const App: React.FC<unknown> = () => {
const location = useLocation();
return (
<Container fluid={true} style={{ padding: '0' }}>
<Row>
<Col sm={6}>Goodbye!</Col>
<Col sm={6}>Hello!</Col>
</Row>
<Row>
<Col sm={3}>
<Menu
selectedKey={
location.pathname === '/'
? 'home'
: location.pathname.replace('/', '')
}
/>
</Col>
<Col>
<Routes>
<Route path='venues' element={<Venues />} />
</Routes>
</Col>
</Row>
</Container>
);
};
export default App;

View File

@@ -0,0 +1,3 @@
export interface IMenuProps {
selectedKey: string;
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { IMenuProps } from './IMenuProps';
import {
Nav,
INavLinkGroup
} from '@fluentui/react/lib/Nav';
const Menu: React.FC<IMenuProps> = (props: IMenuProps) => {
const navLinkGroups: INavLinkGroup[] = [
{
links: [
{
name: 'Home',
url: 'http://localhost:3000',
key: 'home'
},
{
name: 'Venues',
url: 'http://localhost:3000/venues',
key: 'venues'
}
]
}
];
return <Nav groups={navLinkGroups} selectedKey={props.selectedKey} />;
};
export default Menu;

View File

@@ -0,0 +1,6 @@
export interface IPaginatorProps {
totalPageCount: number;
currentPage: number;
pageSize: number;
onPageChange: (newCurrentPage: number) => void;
}

View File

@@ -0,0 +1,23 @@
.paginator {
.paginatorContainer {
display: flex;
direction: row;
justify-content: center;
padding: 20px;
}
.paginatorLink {
display: flex;
direction: row;
justify-content: center;
height: 32px;
margin: 4px 4px 0 4px;
min-width: 24px;
}
.paginatorButton {
button {
background-color: transparent !important;
}
}
}

View File

@@ -0,0 +1,160 @@
import React from 'react';
import { IPaginatorProps } from './IPaginatorProps';
import styles from './Paginator.module.scss';
import {
IconButton
} from '@fluentui/react/lib/Button';
import { Link } from '@fluentui/react/lib/Link';
const range = (start: number, end: number) => {
return [...Array(end).keys()].map((element) => element + start);
};
const Paginator: React.FC<IPaginatorProps> = ({
totalPageCount,
currentPage,
pageSize,
onPageChange
}: IPaginatorProps) => {
const pages: number[] = range(1, totalPageCount);
return (
<div className={styles.paginator}>
<div className={styles.paginatorContainer}>
<div className={styles.paginatorButton}>
<IconButton
iconProps={{ iconName: 'DoubleChevronLeft' }}
disabled={currentPage === 1}
onClick={() => onPageChange(1)}
/>
</div>
<div className={styles.paginatorButton}>
<IconButton
iconProps={{ iconName: 'ChevronLeft' }}
disabled={currentPage === 1}
onClick={() => onPageChange(currentPage - 1)}
/>
</div>
{pages.map((page) => {
return (
<div>
<Link
className={styles.paginatorLink}
disabled={page === currentPage}
onClick={() => onPageChange(page)}
>
{page}
</Link>
</div>
);
})}
<div className={styles.paginatorButton}>
<IconButton
iconProps={{ iconName: 'ChevronRight' }}
disabled={currentPage === totalPageCount}
onClick={() => onPageChange(currentPage + 1)}
/>
</div>
<div className={styles.paginatorButton}>
<IconButton
iconProps={{ iconName: 'DoubleChevronRight' }}
disabled={currentPage === totalPageCount}
onClick={() => onPageChange(totalPageCount)}
/>
</div>
</div>
</div>
);
// const ellipses = '...';
// const paginationRange: (string | number)[] | undefined = useMemo(() => {
// const siblingCount = 2;
// const totalPageNumbers = 1;
// const leftSiblingIndex = Math.max(currentPage - siblingCount, 1);
// console.log(leftSiblingIndex)
// const rightSiblingIndex = Math.min(currentPage + siblingCount, totalPageCount);
// console.log(rightSiblingIndex)
// const shouldShowLeftDots = leftSiblingIndex > 2;
// const shouldShowRightDots = rightSiblingIndex < totalPageCount - 2;
// const firstPageIndex = 1;
// const lastPageIndex = totalPageCount;
// const range = (start: number, end: number) => {
// let length = end - start + 1;
// return Array.from({ length }, (_, idx) => idx + start);
// };
// if (totalPageNumbers >= totalPageCount) {
// return range(1, totalPageCount)
// }
// if (!shouldShowLeftDots && shouldShowRightDots) {
// let leftItemCount = 3 + 2 * siblingCount;
// let leftRange = range(1, leftItemCount);
// return [...leftRange, ellipses, totalPageCount]
// }
// if (shouldShowLeftDots && !shouldShowRightDots) {
// let rightItemCount = 3 + 2 * siblingCount;
// let rightRange = range(
// totalPageCount - rightItemCount + 1,
// totalPageCount
// );
// return [firstPageIndex, ellipses, ...rightRange];
// }
// if (shouldShowLeftDots && shouldShowRightDots) {
// let middleRange = range(leftSiblingIndex, rightSiblingIndex);
// return [firstPageIndex, ellipses, ...middleRange, ellipses, lastPageIndex];
// }
// }, [totalPageCount, currentPage, pageSize]);
// // let lastPage: string | number = paginationRange ? paginationRange[paginationRange.length - 1] :
// const onPrevious = () => {
// onPageChange(currentPage - 1);
// }
// const onNext = () => {
// onPageChange(currentPage + 1);
// }
// useEffect(() => {
// console.log(paginationRange)
// }, [paginationRange])
// return (
// <Container fluid={true} style={{ padding: '0' }}>
// <Row>
// <Col>
// <IconButton
// iconProps={{ iconName: 'ChevronLeft' }}
// disabled={currentPage === 1}
// onClick={onPrevious}
// />
// </Col>
// {paginationRange?.map((pageNumber: any) => {
// if (pageNumber === ellipses) {
// return <div>dots</div>
// } else {
// return <Link
// onClick={() => onPageChange(parseInt(pageNumber))}
// underline
// >
// {pageNumber}
// </Link>
// }
// })}
// <Col>
// <IconButton
// iconProps={{ iconName: 'ChevronRight' }}
// // disabled={currentPage === lastPage}
// onClick={onNext}
// />
// </Col>
// </Row>
// </Container>
// )
};
export default Paginator;

View File

@@ -0,0 +1,13 @@
import { ICommandBarItemProps } from '@fluentui/react/lib/CommandBar';
import { IVenue } from '../../models/IVenue';
export interface IVenuesState {
items: IVenue[];
totalItemCount: number | null;
totalPageCount: number | null;
currentPageNumber: number;
pageSize: number;
selectedItems: IVenue[];
commandBarItems: ICommandBarItemProps[];
isPanelOpen: boolean;
}

View File

@@ -0,0 +1,104 @@
import React from 'react';
import AppContextProvider from '../../context/AppContextProvider';
import Venues from './Venues';
import { MockedProvider } from '@apollo/client/testing';
import { GET_VENUES } from '../../constants/graphQL';
const mocks: any[] = [
{
request: {
query: GET_VENUES,
variables: {
pageNumber: 1,
pageSize: 10
}
},
result: {
data: {
venues: {
currentPage: 1,
errors: null,
message: null,
pageSize: 10,
succeeded: true,
totalItemCount: 55,
totalPageCount: 6,
results: [
{
active: 'True',
id: 32,
link: '/api/v1/venues/32',
name: 'American Family Field'
},
{
active: 'True',
id: 2518,
link: '/api/v1/venues/2518',
name: 'American Family Fields of Phoenix'
},
{
active: true,
id: 1,
link: '/api/v1/venues/1',
name: 'Angel Stadium'
},
{
active: true,
id: 2700,
link: '/api/v1/venues/2700',
name: 'BayCare Ballpark'
},
{
active: true,
id: 2889,
link: '/api/v1/venues/2889',
name: 'Busch Stadium'
},
{
active: true,
id: 3809,
link: '/api/v1/venues/3809',
name: 'Camelback Ranch'
},
{
active: true,
id: 2534,
link: '/api/v1/venues/2534',
name: 'Charlotte Sports Park'
},
{
active: true,
id: 15,
link: '/api/v1/venues/15',
name: 'Chase Field'
},
{
active: true,
id: 3289,
link: '/api/v1/venues/3289',
name: 'Citi Field'
},
{
active: true,
id: 2681,
link: '/api/v1/venues/2681',
name: 'Citizens Bank Park'
}
]
}
}
}
}
];
describe('Venues', () => {
it('renders', () => {
cy.mount(
<AppContextProvider>
<MockedProvider mocks={mocks} addTypename={false}>
<Venues />
</MockedProvider>
</AppContextProvider>
);
});
});

View File

@@ -0,0 +1,177 @@
import React, { useEffect, useReducer } from 'react';
import { useQuery } from '@apollo/client';
import {
CommandBar,
ICommandBarItemProps
} from '@fluentui/react/lib/CommandBar';
import {
DetailsList,
IColumn,
Selection,
SelectionMode
} from '@fluentui/react/lib/DetailsList';
import { MessageBar, MessageBarType } from '@fluentui/react/lib/MessageBar';
import { Panel } from '@fluentui/react/lib/Panel';
import Paginator from '../paginator/Paginator';
import { Spinner, SpinnerSize } from '@fluentui/react/lib/Spinner';
import { initialState, reducer } from './reducer';
import { useAppContext } from '../../hooks/appContext/UseAppContext';
import { GET_VENUES } from '../../constants/graphQL';
import { INotification } from '../../models/INotification';
import { IVenue } from '../../models/IVenue';
const Venues: React.FC<unknown> = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const appContext = useAppContext();
const currentPageNumber = state.currentPageNumber;
const pageSize = state.pageSize;
const { data, loading, error, refetch } = useQuery(GET_VENUES, {
variables: {
pageNumber: currentPageNumber,
pageSize: pageSize
}
});
const columns: IColumn[] = [
{
key: 'name',
name: 'Name',
fieldName: 'name',
minWidth: 100,
isResizable: true
}
];
const selection: Selection = new Selection({
onSelectionChanged: () =>
dispatch({ type: 'SET_SELECTED_ITEM', payload: selection.getSelection() })
});
const onIsPanelOpen = () => {
dispatch({ type: 'SET_IS_PANEL_OPEN', payload: !state.isPanelOpen });
};
const onDismissMessageBar = () => {
const notification: INotification = {
...appContext.state.notification,
isVisible: false
};
appContext.dispatch({ type: 'SET_NOTIFICATION', payload: notification });
};
const onPageChange = (newCurrentPage: number) => {
console.log(newCurrentPage);
dispatch({ type: 'SET_CURRENT_PAGE_NUMBER', payload: newCurrentPage });
refetch({
pageNumber: newCurrentPage,
pageSize: pageSize
});
};
useEffect(() => {
dispatch({
type: 'SET_COMMAND_BAR_ITEMS',
payload: [
{
key: 'openItem',
text: 'Open',
iconProps: { iconName: 'OpenPane' },
onClick: onIsPanelOpen,
disabled: true
}
]
});
}, []);
useEffect(() => {
console.log(JSON.stringify(data));
if (data) {
dispatch({
type: 'SET_ITEMS',
payload: {
items: data.venues.results,
totalItemCount: data.venues.totalItemCount,
totalPageCount: data.venues.totalPageCount
}
});
}
}, [data]);
useEffect(() => {
const notification: INotification = {
messageBarType: MessageBarType.error,
message: error?.message,
isMultiline: false,
isVisible: false
};
appContext.dispatch({ type: 'SET_NOTIFICATION', payload: notification });
}, [error]);
useEffect(() => {
if (state.selectedItems.length > 0 && state.commandBarItems.length > 0) {
const newCommandBarItems = state.commandBarItems.map(
(commandBarItem: ICommandBarItemProps) => {
return {
...commandBarItem,
disabled: false
};
}
);
dispatch({ type: 'SET_COMMAND_BAR_ITEMS', payload: newCommandBarItems });
}
}, [state.selectedItems]);
return (
<div>
{loading && <Spinner size={SpinnerSize.large} />}
{!loading && (
<React.Fragment>
{appContext.state.notification?.isVisible && (
<MessageBar
messageBarType={appContext.state.notification?.messageBarType}
isMultiline={appContext.state.notification?.isMultiline}
onDismiss={onDismissMessageBar}
>
{appContext.state.notification?.message}
</MessageBar>
)}
<CommandBar items={state.commandBarItems} />
<DetailsList
columns={columns}
items={state.items}
selection={selection}
selectionMode={SelectionMode.single}
selectionPreservedOnEmptyClick={true}
/>
<Paginator
totalPageCount={data.venues.totalPageCount}
currentPage={state.currentPageNumber}
pageSize={state.pageSize}
onPageChange={onPageChange}
/>
<Panel
headerText='Venue'
isOpen={state.isPanelOpen}
onDismiss={onIsPanelOpen}
>
{state.selectedItems.length > 0 &&
state.selectedItems.map((item: IVenue) => {
return (
<React.Fragment>
<p>{item.name}</p>
<p>{item.id}</p>
<p>{item.link}</p>
<p>{item.active}</p>
</React.Fragment>
);
})}
</Panel>
</React.Fragment>
)}
</div>
);
};
export default Venues;

View File

@@ -0,0 +1,73 @@
import { IVenuesState } from './IVenuesState';
import { IVenue } from '../../models/IVenue';
import { ICommandBarItemProps } from '@fluentui/react/lib/CommandBar';
import { IObjectWithKey } from '@fluentui/react';
export type Action =
| {
type: 'SET_ITEMS';
payload: {
items: IVenue[];
totalItemCount: number;
totalPageCount: number;
};
}
| { type: 'SET_CURRENT_PAGE_NUMBER'; payload: number }
| { type: 'SET_PAGE_SIZE'; payload: number }
| { type: 'SET_SELECTED_ITEM'; payload: IObjectWithKey[] }
| { type: 'SET_COMMAND_BAR_ITEMS'; payload: ICommandBarItemProps[] }
| { type: 'SET_IS_PANEL_OPEN'; payload: boolean };
export const initialState: IVenuesState = {
items: [],
totalItemCount: null,
totalPageCount: null,
currentPageNumber: 1,
pageSize: 10,
selectedItems: [],
commandBarItems: [],
isPanelOpen: false
};
export const reducer = (state: IVenuesState, action: Action): IVenuesState => {
switch (action.type) {
case 'SET_ITEMS': {
return {
...state,
items: action.payload.items,
totalItemCount: action.payload.totalItemCount,
totalPageCount: action.payload.totalPageCount
};
}
case 'SET_CURRENT_PAGE_NUMBER': {
return {
...state,
currentPageNumber: action.payload
};
}
case 'SET_PAGE_SIZE': {
return {
...state,
pageSize: action.payload
};
}
case 'SET_SELECTED_ITEM': {
return {
...state,
selectedItems: <IVenue[]>action.payload
};
}
case 'SET_COMMAND_BAR_ITEMS': {
return {
...state,
commandBarItems: action.payload
};
}
case 'SET_IS_PANEL_OPEN': {
return {
...state,
isPanelOpen: action.payload
};
}
}
};

View File

@@ -0,0 +1,21 @@
import { gql } from '@apollo/client';
export const GET_VENUES = gql`
query GetVenues($pageNumber: Int, $pageSize: Int) {
venues(pageNumber: $pageNumber, pageSize: $pageSize) {
currentPage
errors
message
pageSize
succeeded
totalItemCount
totalPageCount
results {
active
id
link
name
}
}
}
`;

View File

@@ -0,0 +1,5 @@
import { Context, createContext } from 'react';
import { IAppContextProps } from './IAppContextProps';
export const AppContext: Context<IAppContextProps> =
createContext<IAppContextProps>({} as IAppContextProps);

View File

@@ -0,0 +1,25 @@
import React, { useMemo, useReducer } from 'react';
import { AppContext } from './AppContext';
import { IAppContextProps } from './IAppContextProps';
import { IAppContextProviderProps } from './IAppContextProviderProps';
import { initialState, reducer } from './reducer';
const AppContextProvider: React.FC<IAppContextProviderProps> = (
props: IAppContextProviderProps
) => {
const [state, dispatch] = useReducer(reducer, initialState);
const contextValue: IAppContextProps = useMemo(() => {
return {
state,
dispatch
};
}, [state, dispatch]);
return (
<AppContext.Provider value={contextValue}>
{props.children}
</AppContext.Provider>
);
};
export default AppContextProvider;

View File

@@ -0,0 +1,8 @@
import React from 'react';
import { IAppContextState } from './IAppContextState';
import { Action } from './reducer';
export interface IAppContextProps {
state: IAppContextState;
dispatch: React.Dispatch<Action>;
}

View File

@@ -0,0 +1,5 @@
import React from 'react';
export interface IAppContextProviderProps {
children: React.ReactNode;
}

View File

@@ -0,0 +1,5 @@
import { INotification } from '../models/INotification';
export interface IAppContextState {
notification: INotification | undefined;
}

View File

@@ -0,0 +1,28 @@
import { MessageBarType } from '@fluentui/react';
import { INotification } from '../models/INotification';
import { IAppContextState } from './IAppContextState';
export type Action = { type: 'SET_NOTIFICATION'; payload: INotification };
export const initialState: IAppContextState = {
notification: {
messageBarType: MessageBarType.info,
message: 'Blah blah blah',
isMultiline: false,
isVisible: true
}
};
export const reducer = (
state: IAppContextState,
action: Action
): IAppContextState => {
switch (action.type) {
case 'SET_NOTIFICATION': {
return {
...state,
notification: action.payload
};
}
}
};

View File

@@ -0,0 +1,11 @@
import { useContext } from 'react';
import { AppContext } from '../../context/AppContext';
export const useAppContext = () => {
const { state, dispatch } = useContext(AppContext);
return {
state,
dispatch
};
};

View File

@@ -0,0 +1,6 @@
export interface IUsePaginationProps {
totalCount: number;
pageSize: number;
siblingCount: number;
currentPage: number;
}

View File

@@ -0,0 +1,71 @@
import { useMemo } from 'react';
import { IUsePaginationProps } from './IUsePaginationProps';
export const DOTS = '...';
const range = (start: number, end: number) => {
const length = end - start + 1;
return Array.from({ length }, (_, idx) => idx + start);
};
export const usePagination = ({
totalCount,
pageSize,
siblingCount = 1,
currentPage
}: IUsePaginationProps) => {
const paginationRange = useMemo(() => {
const totalPageCount = Math.ceil(totalCount / pageSize);
// Pages count is determined as siblingCount + firstPage + lastPage + currentPage + 2*DOTS
const totalPageNumbers = siblingCount + 5;
/*
If the number of pages is less than the page numbers we want to show in our
paginationComponent, we return the range [1..totalPageCount]
*/
if (totalPageNumbers >= totalPageCount) {
return range(1, totalPageCount);
}
const leftSiblingIndex = Math.max(currentPage - siblingCount, 1);
const rightSiblingIndex = Math.min(
currentPage + siblingCount,
totalPageCount
);
/*
We do not want to show dots if there is only one position left
after/before the left/right page count as that would lead to a change if our Pagination
component size which we do not want
*/
const shouldShowLeftDots = leftSiblingIndex > 2;
const shouldShowRightDots = rightSiblingIndex < totalPageCount - 2;
const firstPageIndex = 1;
const lastPageIndex = totalPageCount;
if (!shouldShowLeftDots && shouldShowRightDots) {
const leftItemCount = 3 + 2 * siblingCount;
const leftRange = range(1, leftItemCount);
return [...leftRange, DOTS, totalPageCount];
}
if (shouldShowLeftDots && !shouldShowRightDots) {
const rightItemCount = 3 + 2 * siblingCount;
const rightRange = range(
totalPageCount - rightItemCount + 1,
totalPageCount
);
return [firstPageIndex, DOTS, ...rightRange];
}
if (shouldShowLeftDots && shouldShowRightDots) {
const middleRange = range(leftSiblingIndex, rightSiblingIndex);
return [firstPageIndex, DOTS, ...middleRange, DOTS, lastPageIndex];
}
}, [totalCount, pageSize, siblingCount, currentPage]);
return paginationRange;
};

25
app/src/index.tsx Normal file
View File

@@ -0,0 +1,25 @@
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { BrowserRouter as Router } from 'react-router-dom';
import { ApolloClient, InMemoryCache, ApolloProvider, NormalizedCacheObject } from '@apollo/client';
import AppContextProvider from './context/AppContextProvider';
import App from './components/app/App';
const client: ApolloClient<NormalizedCacheObject> = new ApolloClient({
uri: 'http://localhost:8080/v1/graphql',
cache: new InMemoryCache()
});
const container = document.getElementById('app-root') as HTMLElement;
const root: Root = createRoot(container);
root.render(
<React.StrictMode>
<AppContextProvider>
<ApolloProvider client={client}>
<Router>
<App />
</Router>
</ApolloProvider>
</AppContextProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,8 @@
import { MessageBarType } from '@fluentui/react/lib/MessageBar';
export interface INotification {
messageBarType?: MessageBarType;
message?: string;
isMultiline?: boolean;
isVisible: boolean;
}

6
app/src/models/IVenue.ts Normal file
View File

@@ -0,0 +1,6 @@
export interface IVenue {
active: boolean;
id: number;
link: string;
name: string;
}

19
app/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"baseUrl": ".",
"esModuleInterop": true,
"jsx": "react",
"module": "esnext",
"moduleResolution": "node",
"lib": [
"dom",
"esnext"
],
"strict": true,
"sourceMap": true,
"target": "esnext",
},
"exclude": [
"node_modules"
]
}

39
app/webpack.config.js Normal file
View File

@@ -0,0 +1,39 @@
const prod = process.env.NODE_ENV === 'production';
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
mode: prod ? 'production' : 'development',
entry: './src/index.tsx',
output: {
path: __dirname + '/dist/',
publicPath: '/'
},
module: {
rules: [
{
test: /\.(ts|js)x?$/,
exclude: /node_modules/,
resolve: {
extensions: ['.ts', '.tsx', '.js', '.json'],
},
use: 'babel-loader',
},
{
test: /\.(s(a|c)ss)$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
}
]
},
devServer: {
historyApiFallback: true
},
devtool: prod ? undefined : 'source-map',
plugins: [
new HtmlWebpackPlugin({
template: 'index.html',
}),
new MiniCssExtractPlugin(),
],
};