diff --git a/README.md b/README.md index 28aefdb..d3d6e39 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,6 @@ When you are transitioning a Rails Component to React or you are building a new 3. Write your component and provide type information for its props 1. If your component has a state that would make sense to communicate to a parent component make sure to provide callback functions that are triggered on state changes 4. Write tests for your components using jest - 1. You can also add a Story to your component using Storybook to more easily visualize how arguments affect your component and how to interact with it. See an example in [EventSelector.stories.ts](src%2Fcomponents%2FEventSelector%2FEventSelector.stories.ts) + 1. You can also add a Story to your component using Storybook to more easily visualize how arguments affect your component and how to interact with it. See an example in [EventSelector.stories.tsx](src%2Fcomponents%2FEventSelector%2FEventSelector.stories.ts) 5. Create an index.ts in your directory to export your component and add its desired exported name to [src/components/index.ts](src%2Fcomponents%2Findex.ts) 6. Create a PR with your changes \ No newline at end of file diff --git a/package.json b/package.json index 3b47f7d..8e62058 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@thewca/wca-components", - "version": "0.4.0", + "version": "0.5.0", "description": "The WCA React Component Library", "repository": { "type": "git", diff --git a/src/components/CubingIcon/CubingIcon.tsx b/src/components/CubingIcon/CubingIcon.tsx index 956165b..e66d21d 100644 --- a/src/components/CubingIcon/CubingIcon.tsx +++ b/src/components/CubingIcon/CubingIcon.tsx @@ -1,6 +1,7 @@ import './cubingicon.scss' -import { EventId } from '@wca/helpers' +import { EventId, getEventName } from '@wca/helpers' import React from 'react' +import { Popup } from 'semantic-ui-react' export type IconSize = '1x' | '2x' | '3x' | '4x' | '5x' @@ -16,10 +17,17 @@ export default function CubingIcon({ size = '1x', }: CubingIconProps) { return ( - + + } + > + {getEventName(event)} + ) } diff --git a/src/components/EventSelector/EventSelector.stories.ts b/src/components/EventSelector/EventSelector.stories.ts deleted file mode 100644 index b8aa944..0000000 --- a/src/components/EventSelector/EventSelector.stories.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react' -import EventSelector from './EventSelector' - -const meta: Meta = { - title: 'WCA-Components/EventSelector', - component: EventSelector, - // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs - tags: ['autodocs'], - // More on argTypes: https://storybook.js.org/docs/react/api/argtypes -} -export default meta - -type Story = StoryObj - -export const AllEvents: Story = { - // More on args: https://storybook.js.org/docs/react/writing-stories/args - args: { - initialSelected: [], - events: [ - '333', - '222', - '444', - '555', - '666', - '777', - '333bf', - '333fm', - '333oh', - 'clock', - 'minx', - 'pyram', - 'skewb', - 'sq1', - '444bf', - '555bf', - '333mbf', - ], - }, -} - -export const SomePreSelected: Story = { - // More on args: https://storybook.js.org/docs/react/writing-stories/args - args: { - initialSelected: ['333'], - events: ['333', '444', '555'], - }, -} diff --git a/src/components/EventSelector/EventSelector.stories.tsx b/src/components/EventSelector/EventSelector.stories.tsx new file mode 100644 index 0000000..3c57840 --- /dev/null +++ b/src/components/EventSelector/EventSelector.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { EventId } from '@wca/helpers' +import React, { useState } from 'react' +import EventSelector from './EventSelector' + +const meta: Meta = { + title: 'WCA-Components/EventSelector', + component: EventSelector, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs + tags: ['autodocs'], + // More on argTypes: https://storybook.js.org/docs/react/api/argtypes +} +export default meta + +type Story = StoryObj + +export const AllEvents: Story = { + // More on args: https://storybook.js.org/docs/react/writing-stories/args + args: { + selected: [], + events: [ + '333', + '222', + '444', + '555', + '666', + '777', + '333bf', + '333fm', + '333oh', + 'clock', + 'minx', + 'pyram', + 'skewb', + 'sq1', + '444bf', + '555bf', + '333mbf', + ], + }, + decorators: [ + (DecoratedStory) => { + const [selected, setSelected] = useState([]) + return ( + + ) + }, + ], +} + +export const SomePreSelected: Story = { + // More on args: https://storybook.js.org/docs/react/writing-stories/args + args: { + selected: ['333'], + events: ['333', '444', '555'], + }, + decorators: [ + (DecoratedStory) => { + const [selected, setSelected] = useState(['333']) + return ( + + ) + }, + ], +} diff --git a/src/components/EventSelector/EventSelector.test.tsx b/src/components/EventSelector/EventSelector.test.tsx index 152d4c2..a98f48b 100644 --- a/src/components/EventSelector/EventSelector.test.tsx +++ b/src/components/EventSelector/EventSelector.test.tsx @@ -9,7 +9,7 @@ describe('EventSelector', () => { handleEventSelection={() => {}} events={['333']} size="2x" - initialSelected={[]} + selected={[]} /> ) }) diff --git a/src/components/EventSelector/EventSelector.tsx b/src/components/EventSelector/EventSelector.tsx index 37fc97f..465a185 100644 --- a/src/components/EventSelector/EventSelector.tsx +++ b/src/components/EventSelector/EventSelector.tsx @@ -1,6 +1,6 @@ import './eventselector.scss' import { EventId } from '@wca/helpers' -import React, { useState } from 'react' +import React from 'react' import CubingIcon from '../CubingIcon' import { IconSize } from '../CubingIcon/CubingIcon' @@ -8,48 +8,41 @@ type HandleEventSelectionCallback = (events: EventId[]) => void interface EventSelectorProps { handleEventSelection: HandleEventSelectionCallback - initialSelected: EventId[] + selected: EventId[] events: EventId[] size: IconSize } export default function EventSelector({ handleEventSelection, + selected, events, - initialSelected, size = '2x', }: EventSelectorProps) { - const [selectedEvents, setSelectedEvents] = - useState(initialSelected) - const handleEventToggle = (event: EventId) => { - if (selectedEvents.includes(event)) { - const newEvents = selectedEvents.filter( + if (selected.includes(event)) { + const newEvents = selected.filter( (selectedEvent) => selectedEvent !== event ) - setSelectedEvents(newEvents) handleEventSelection(newEvents) } else { - const newEvents = [...selectedEvents, event] - setSelectedEvents(newEvents) + const newEvents = [...selected, event] handleEventSelection(newEvents) } } const setAllEvents = () => { - setSelectedEvents(events) handleEventSelection(events) } const clearAllEvents = () => { - setSelectedEvents([]) handleEventSelection([]) } return ( - Events ({selectedEvents.length}) + Events ({selected.length}) {' '} All{' '} @@ -64,7 +57,7 @@ export default function EventSelector({ { - test('renders the NonInteractiveTable component', () => { - render( - - ) - }) -}) diff --git a/src/components/NonInteractiveTable/noninteractivetable.scss b/src/components/NonInteractiveTable/noninteractivetable.scss index 9816008..e69de29 100644 --- a/src/components/NonInteractiveTable/noninteractivetable.scss +++ b/src/components/NonInteractiveTable/noninteractivetable.scss @@ -1,42 +0,0 @@ -.floatThead-container{ - overflow-x: hidden; - width: 100%; -} -.floatThead-table{ - border-collapse: collapse; - border: 0 none rgba(0, 0, 0, 0.87); - display: table; - margin: 0; - table-layout: fixed; -} -.floatThead-col{ - width: auto; -} -.table-condensed > thead > tr > th, .table-condensed > thead > tr > td, .table-condensed > tbody > tr > th, .table-condensed > tbody > tr > td, .table-condensed > tfoot > tr > th, .table-condensed > tfoot > tr > td { - padding: 5px; -} -.table > thead > tr > th, .table > thead > tr > td, .table > tbody > tr > th, .table > tbody > tr > td, .table > tfoot > tr > th, .table > tfoot > tr > td { - padding: 8px; - line-height: 1.428571429; - vertical-align: top; - border-top: 1px solid #ddd; -} -.table th { - text-align:left; -} - -table.table tfoot { - font-weight: bold; -} -table.table-greedy-last-column, -#person table.table-greedy-last-column { - white-space:nowrap -} -table.table-greedy-last-column>thead>tr>th:last-child, -#person table.table-greedy-last-column>thead>tr>th:last-child { - width:100% -} -table.table-greedy-last-column>tbody>tr>td:last-child, -#person table.table-greedy-last-column>tbody>tr>td:last-child { - width:100% -} \ No newline at end of file diff --git a/src/components/RegistrationTable/RegistrationsTable.stories.tsx b/src/components/RegistrationTable/RegistrationsTable.stories.tsx new file mode 100644 index 0000000..3d8f28d --- /dev/null +++ b/src/components/RegistrationTable/RegistrationsTable.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/react' +import RegistrationsTable from './RegistrationsTable' + +const meta: Meta = { + title: 'WCA-Components/RegistrationsTable', + component: RegistrationsTable, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs + tags: ['autodocs'], + // More on argTypes: https://storybook.js.org/docs/react/api/argtypes +} +export default meta + +type Story = StoryObj + +export const Competitors: Story = { + // More on args: https://storybook.js.org/docs/react/writing-stories/args + args: { + registrations: [ + { + user_id: '1', + event_ids: ['333', '222', '444'], + user: { + id: '1', + country: { iso2: 'nl', name: 'Netherlands', id: 'nl' }, + name: 'Ron van Bruchem', + wca_id: '2003BRUC01', + }, + }, + { + user_id: '6427', + event_ids: ['222', '333bf'], + user: { + id: '6427', + country: { iso2: 'uk', name: 'United Kingdom', id: 'uk' }, + name: 'Joey Gouly', + wca_id: '2007GOUL01', + }, + }, + { + user_id: '999999', + event_ids: ['333', '222'], + user: { + id: '999999', + country: { iso2: 'us', name: 'United States', id: 'us' }, + name: 'Alex Newcomer', + }, + }, + ], + heldEvents: ['333', '222', '444', '555', '333bf'], + }, +} + +export const Empty: Story = { + // More on args: https://storybook.js.org/docs/react/writing-stories/args + args: { + registrations: [], + heldEvents: ['333', '333bf'], + }, +} diff --git a/src/components/RegistrationTable/RegistrationsTable.test.tsx b/src/components/RegistrationTable/RegistrationsTable.test.tsx new file mode 100644 index 0000000..e1ea7b8 --- /dev/null +++ b/src/components/RegistrationTable/RegistrationsTable.test.tsx @@ -0,0 +1,25 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { RegistrationsTable } from '../index' + +describe('RegistrationsTable', () => { + test('renders the RegistrationsTable component', () => { + render( + + ) + }) +}) diff --git a/src/components/RegistrationTable/RegistrationsTable.tsx b/src/components/RegistrationTable/RegistrationsTable.tsx new file mode 100644 index 0000000..627cb2c --- /dev/null +++ b/src/components/RegistrationTable/RegistrationsTable.tsx @@ -0,0 +1,216 @@ +import './registrationtable.scss' +import { EventId } from '@wca/helpers' +import React, { useReducer } from 'react' +import { Table } from 'semantic-ui-react' +import CubingIcon from '../CubingIcon' +import FlagIcon from '../FlagIcon' + +interface Registration { + user_id: string + user: { + id: string + wca_id?: string + name: string + country: { + id: string + name: string + iso2: string + } + } + event_ids: EventId[] +} + +interface RegistrationTableProps { + registrations: Registration[] + heldEvents: EventId[] +} + +type Direction = 'ascending' | 'descending' | undefined + +interface SortState { + column: string + data: Registration[] + direction: Direction +} +function sortReducer( + state: SortState, + action: { type: string; column: string } +): SortState { + if (action.type === 'CHANGE_SORT') { + if (state.column === action.column) { + return { + ...state, + data: state.data.slice().reverse(), + direction: state.direction === 'ascending' ? 'descending' : 'ascending', + } + } + switch (action.column) { + case 'name': { + return { + column: action.column, + data: state.data.sort((a, b) => + a.user.name.localeCompare(b.user.name) + ), + direction: 'ascending', + } + } + case 'country': { + return { + column: action.column, + data: state.data.sort((a, b) => + a.user.country.name.localeCompare(b.user.country.name) + ), + direction: 'ascending', + } + } + case 'total': { + return { + column: action.column, + data: state.data.sort( + (a, b) => a.event_ids.length - b.event_ids.length + ), + direction: 'ascending', + } + } + default: { + throw new Error('Unknown Column') + } + } + } + throw new Error('Unknown Action') +} + +export default function RegistrationsTable({ + registrations, + heldEvents, +}: RegistrationTableProps) { + const [state, dispatch] = useReducer(sortReducer, { + column: '', + data: registrations, + direction: undefined, + }) + const { column, data, direction } = state + const { newcomers, totalEvents, countrySet, eventCounts } = data.reduce( + (info, registration) => { + if (registration.user.wca_id === undefined) { + info.newcomers++ + } + info.countrySet.add(registration.user.country.iso2) + info.totalEvents += registration.event_ids.length + heldEvents.forEach((id) => { + if (registration.event_ids.includes(id)) { + // We can safely ignore the undefined case here because we initialize the map with zeroes + info.eventCounts.set(id, (info.eventCounts.get(id) as number) + 1) + } + }) + return info + }, + { + newcomers: 0, + totalEvents: 0, + countrySet: new Set(), + // We have to use a Map instead of an object to preserve event order + eventCounts: heldEvents.reduce((counts, eventId) => { + counts.set(eventId, 0) + return counts + }, new Map()), + } + ) + return ( + + + + + dispatch({ type: 'CHANGE_SORT', column: 'name' })} + > + Name + + + dispatch({ type: 'CHANGE_SORT', column: 'country' }) + } + > + Citizen Of + + {heldEvents.map((id) => ( + + + + ))} + dispatch({ type: 'CHANGE_SORT', column: 'total' })} + > + Total + + + + + {data.length > 0 ? ( + data.map((registration) => ( + + + {registration.user.wca_id ? ( + + {registration.user.name} + + ) : ( + registration.user.name + )} + + + + {registration.user.country.name} + + {heldEvents.map((id) => { + if (registration.event_ids.includes(id)) { + return ( + + + + ) + } + return ( + + ) + })} + {registration.event_ids.length} + + )) + ) : ( + + No matching records found + + )} + + + + {`${newcomers} First-Timers + ${ + registrations.length - newcomers + } Returners = ${registrations.length} People`} + {`${countrySet.size} Countries`} + {[...eventCounts.entries()].map(([id, count]) => ( + {count} + ))} + {totalEvents} + + + + + ) +} diff --git a/src/components/RegistrationTable/index.ts b/src/components/RegistrationTable/index.ts new file mode 100644 index 0000000..b1886ea --- /dev/null +++ b/src/components/RegistrationTable/index.ts @@ -0,0 +1 @@ +export { default } from './RegistrationsTable' diff --git a/src/components/RegistrationTable/registrationtable.scss b/src/components/RegistrationTable/registrationtable.scss new file mode 100644 index 0000000..1b71606 --- /dev/null +++ b/src/components/RegistrationTable/registrationtable.scss @@ -0,0 +1,29 @@ +.registrations-table-wrapper{ + border-radius: 23px; + background: #D9D9D9; + padding: 19px; +} +.registrations-table-header-item{ + border-bottom: 2px solid black !important; + font-size: 20px !important; + padding: 20px 8px 20px 8px !important; + margin-bottom: 10px; + background: #D9D9D9 !important; +} +.registrations-table{ + border: none !important; + tbody > tr > td { + padding: 15px 0 15px 10px !important; + border-bottom: 15px solid transparent; + background-color: white; + color: black; + background-clip: padding-box; + } + tbody{ + background-color: #D9D9D9; + } +} +.registrations-table-header{ + background: #D9D9D9 !important; + padding-bottom: 10px; +} \ No newline at end of file diff --git a/src/components/index.ts b/src/components/index.ts index c7a7992..ac2d7fd 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -3,6 +3,6 @@ export { default as EventSelector } from './EventSelector' export { default as FlagIcon } from './FlagIcon' export { default as Footer } from './Footer' export { default as Header } from './Header' -export { default as NonInteractiveTable } from './NonInteractiveTable' +export { default as RegistrationsTable } from './RegistrationTable' export { default as Sidebar } from './Sidebar' export { default as UiIcon } from './UiIcon'