Skip to content

Commit fc72f5e

Browse files
authored
Merge pull request #295 from TaloDev/develop
Release 0.36.0
2 parents dee5821 + 2281440 commit fc72f5e

File tree

10 files changed

+183
-30
lines changed

10 files changed

+183
-30
lines changed

package-lock.json

Lines changed: 27 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
"lint-staged": {
7979
"*.{ts,js,tsx,jsx}": "eslint --fix"
8080
},
81-
"version": "0.35.0",
81+
"version": "0.36.0",
8282
"engines": {
8383
"node": "20.x"
8484
},

src/api/toggledPinnedGroup.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { z } from 'zod'
2+
import api from './api'
3+
import makeValidatedRequest from './makeValidatedRequest'
4+
5+
const togglePinnedGroup = makeValidatedRequest(
6+
(gameId: number, groupId: string, pinned: boolean) => api.put(`/games/${gameId}/player-groups/${groupId}/toggle-pinned`, { pinned }),
7+
z.literal('')
8+
)
9+
10+
export default togglePinnedGroup

src/api/usePinnedGroups.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import useSWR from 'swr'
2+
import buildError from '../utils/buildError'
3+
import { Game } from '../entities/game'
4+
import makeValidatedGetRequest from './makeValidatedGetRequest'
5+
import { playerGroupSchema } from '../entities/playerGroup'
6+
import { z } from 'zod'
7+
8+
export default function usePinnedGroups(activeGame: Game | null) {
9+
const fetcher = async ([url]: [string]) => {
10+
const res = await makeValidatedGetRequest(url, z.object({
11+
groups: z.array(playerGroupSchema)
12+
}))
13+
14+
return res
15+
}
16+
17+
const { data, error, mutate } = useSWR(
18+
activeGame ? [`games/${activeGame.id}/player-groups/pinned`] : null,
19+
fetcher
20+
)
21+
22+
return {
23+
groups: data?.groups ?? [],
24+
loading: !data && !error,
25+
error: error && buildError(error),
26+
mutate
27+
}
28+
}

src/components/toast/ToastContext.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import React from 'react'
22

33
export enum ToastType {
44
NONE = '',
5-
SUCCESS = 'success'
5+
SUCCESS = 'success',
6+
ERROR = 'error'
67
}
78

89
export type ToastContextType = {

src/components/toast/ToastProvider.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { useCallback, useState } from 'react'
22
import { AnimatePresence, motion } from 'framer-motion'
33
import ToastContext, { ToastType } from './ToastContext.ts'
4-
import { IconCheck } from '@tabler/icons-react'
4+
import { IconCheck, IconX } from '@tabler/icons-react'
55
import { useEffect } from 'react'
66
import type { ReactNode } from 'react'
7+
import clsx from 'clsx'
78

89
type ToastProviderProps = {
910
children: ReactNode
@@ -29,6 +30,8 @@ export default function ToastProvider({
2930
switch (type) {
3031
case ToastType.SUCCESS:
3132
return <IconCheck size={24} />
33+
case ToastType.ERROR:
34+
return <IconX size={24} />
3235
default:
3336
return null
3437
}
@@ -70,7 +73,7 @@ export default function ToastProvider({
7073
<AnimatePresence>
7174
{show &&
7275
<motion.div
73-
className='fixed bottom-0 md:bottom-8 md:left-8 z-[51] w-full md:w-auto md:min-w-80 space-x-2 flex items-center md:rounded p-4 md:pr-8 shadow-md bg-indigo-500 text-white'
76+
className={clsx('fixed bottom-0 md:bottom-8 md:left-8 z-[51] w-full md:w-auto md:min-w-80 space-x-2 flex items-center md:rounded p-4 md:pr-8 shadow-md bg-indigo-500 text-white', { 'bg-red-500': type === ToastType.ERROR })}
7477
initial={{ opacity: 0, scale: 0.95, y: 40 }}
7578
animate={{
7679
opacity: 1,

src/modals/groups/__tests__/GroupDetails.test.tsx

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,25 @@ import activeGameState from '../../../state/activeGameState'
77
import userState from '../../../state/userState'
88
import KitchenSink from '../../../utils/KitchenSink'
99
import { UserType } from '../../../entities/user'
10-
import { PlayerGroupRuleCastType, PlayerGroupRuleMode, PlayerGroupRuleName } from '../../../entities/playerGroup'
10+
import { PlayerGroup, PlayerGroupRuleCastType, PlayerGroupRuleMode, PlayerGroupRuleName } from '../../../entities/playerGroup'
1111

1212
describe('<GroupDetails />', () => {
1313
const axiosMock = new MockAdapter(api)
1414
const activeGameValue = { id: '1', name: 'Shattered' }
1515
axiosMock.onGet(/(.*)preview-count(.*)/).reply(200, { count: 8 })
1616

1717
it('should create a group', async () => {
18-
axiosMock.onPost('http://talo.api/games/1/player-groups').replyOnce(200, { group: { id: 2 } })
18+
const secondGroup = {
19+
id: '1',
20+
name: 'Losers',
21+
description: 'Players who have lost the game',
22+
rules: [],
23+
ruleMode: PlayerGroupRuleMode.AND,
24+
count: 0,
25+
updatedAt: new Date().toISOString()
26+
}
27+
28+
axiosMock.onPost('http://talo.api/games/1/player-groups').replyOnce(200, { group: secondGroup })
1929

2030
const closeMock = vi.fn()
2131
const mutateMock = vi.fn()
@@ -43,13 +53,21 @@ describe('<GroupDetails />', () => {
4353

4454
expect(mutateMock).toHaveBeenCalled()
4555

46-
const groups = [
47-
{ id: '1' }
56+
const groups: PlayerGroup[] = [
57+
{
58+
id: '1',
59+
name: 'Winners',
60+
description: 'Players who have won the game',
61+
rules: [],
62+
ruleMode: PlayerGroupRuleMode.AND,
63+
count: 0,
64+
updatedAt: new Date().toISOString()
65+
}
4866
]
4967

5068
const mutator = mutateMock.mock.calls[0][0]
5169
expect(mutator({ groups })).toStrictEqual({
52-
groups: [...groups, { id: 2 }]
70+
groups: [...groups, secondGroup]
5371
})
5472
})
5573

src/pages/Dashboard.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import SecondaryNav from '../components/SecondaryNav'
1515
import DevDataStatus from '../components/DevDataStatus'
1616
import SecondaryTitle from '../components/SecondaryTitle'
1717
import useIntendedRoute from '../utils/useIntendedRoute'
18+
import usePinnedGroups from '../api/usePinnedGroups'
1819

1920
export const secondaryNavRoutes = [
2021
{ title: 'Dashboard', to: routes.dashboard },
@@ -44,6 +45,7 @@ export default function Dashboard() {
4445
const { startDate, endDate } = useTimePeriod(timePeriod)
4546
const { headlines, loading: headlinesLoading, error: headlinesError } = useHeadlines(activeGame, startDate, endDate, includeDevData)
4647
const { stats, loading: statsLoading, error: statsError } = useStats(activeGame, includeDevData)
48+
const { groups: pinnedGroups, loading: pinnedGroupsLoading, error: pinnedGroupsError } = usePinnedGroups(activeGame)
4749

4850
const intendedRouteChecked = useIntendedRoute()
4951

@@ -89,6 +91,20 @@ export default function Dashboard() {
8991
</div>
9092
}
9193

94+
{pinnedGroups.length > 0 && <SecondaryTitle>Pinned groups</SecondaryTitle>}
95+
96+
{pinnedGroupsError &&
97+
<ErrorMessage error={{ message: 'Couldn\'t fetch pinned groups' }} />
98+
}
99+
100+
{!pinnedGroupsLoading &&
101+
<div className='grid md:grid-cols-2 lg:grid-cols-4 gap-4'>
102+
{pinnedGroups.map((group) => (
103+
<HeadlineStat key={group.id} title={group.name} stat={group.count} />
104+
))}
105+
</div>
106+
}
107+
92108
{stats.length > 0 && <SecondaryTitle>Global stats</SecondaryTitle>}
93109

94110
{statsError &&

src/pages/Groups.tsx

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { useEffect, useState } from 'react'
1+
import { useCallback, useContext, useEffect, useState } from 'react'
22
import { useRecoilValue } from 'recoil'
33
import activeGameState, { SelectedActiveGame } from '../state/activeGameState'
44
import ErrorMessage from '../components/ErrorMessage'
55
import { format } from 'date-fns'
6-
import { IconPlus } from '@tabler/icons-react'
6+
import { IconPinned, IconPinnedFilled, IconPlus } from '@tabler/icons-react'
77
import Button from '../components/Button'
88
import TableCell from '../components/tables/TableCell'
99
import TableBody from '../components/tables/TableBody'
@@ -17,6 +17,10 @@ import Identifier from '../components/Identifier'
1717
import { useNavigate } from 'react-router-dom'
1818
import routes from '../constants/routes'
1919
import { PlayerGroup } from '../entities/playerGroup'
20+
import Tippy from '@tippyjs/react'
21+
import usePinnedGroups from '../api/usePinnedGroups'
22+
import ToastContext, { ToastType } from '../components/toast/ToastContext'
23+
import togglePinnedGroup from '../api/toggledPinnedGroup'
2024

2125
export default function Groups() {
2226
const activeGame = useRecoilValue(activeGameState) as SelectedActiveGame
@@ -27,8 +31,12 @@ export default function Groups() {
2731
const { groups, loading, error, mutate } = useGroups(activeGame)
2832
const sortedGroups = useSortedItems(groups, 'name', 'asc')
2933

34+
const { groups: pinnedGroups, mutate: mutatePinnedGroups } = usePinnedGroups(activeGame)
35+
3036
const navigate = useNavigate()
3137

38+
const toast = useContext(ToastContext)
39+
3240
useEffect(() => {
3341
if (!showModal) setEditingGroup(null)
3442
}, [showModal, editingGroup])
@@ -42,6 +50,25 @@ export default function Groups() {
4250
navigate(`${routes.players}?search=group:${group.id}`)
4351
}
4452

53+
const isPinned = useCallback((group: PlayerGroup) => {
54+
return pinnedGroups.find((pg) => pg.id === group.id)
55+
}, [pinnedGroups])
56+
57+
const togglePinned = useCallback(async (group: PlayerGroup) => {
58+
try {
59+
await togglePinnedGroup(activeGame.id, group.id, !isPinned(group))
60+
mutatePinnedGroups((data) => ({
61+
...data,
62+
groups: isPinned(group)
63+
? data!.groups.filter((pg) => pg.id !== group.id)
64+
: [...data!.groups, group]
65+
}), false)
66+
toast.trigger(isPinned(group) ? 'Group unpinned' : 'Group pinned to your dashboard', ToastType.SUCCESS)
67+
} catch (err) {
68+
toast.trigger('Failed to pin group', ToastType.ERROR)
69+
}
70+
}, [activeGame.id, isPinned, mutatePinnedGroups, toast])
71+
4572
return (
4673
<Page
4774
title='Groups'
@@ -70,7 +97,31 @@ export default function Groups() {
7097
{(group) => (
7198
<>
7299
<TableCell className='min-w-80'>
73-
<Identifier id={group.id} />
100+
<div className='flex flex-row items-center space-x-4'>
101+
{pinnedGroups &&
102+
<Tippy content={<p>{isPinned(group) ? 'Unpin group' : 'Pin group to dashboard'}</p>}>
103+
<div className='inline-block'>
104+
{isPinned(group) &&
105+
<Button
106+
variant='icon'
107+
className='p-1 rounded-full bg-indigo-900'
108+
icon={<IconPinnedFilled />}
109+
onClick={() => togglePinned(group)}
110+
/>
111+
}
112+
{!isPinned(group) &&
113+
<Button
114+
variant='icon'
115+
className='p-1 rounded-full bg-indigo-900'
116+
icon={<IconPinned />}
117+
onClick={() => togglePinned(group)}
118+
/>
119+
}
120+
</div>
121+
</Tippy>
122+
}
123+
<Identifier id={group.id} />
124+
</div>
74125
</TableCell>
75126
<TableCell className='min-w-[320px] max-w-[320px] lg:min-w-0'>
76127
{group.name}

0 commit comments

Comments
 (0)