diff --git a/app/jest.config.js b/app/jest.config.js index 8d76dd2..b34295c 100644 --- a/app/jest.config.js +++ b/app/jest.config.js @@ -27,5 +27,5 @@ module.exports = { }, }, - setupFilesAfterEnv: ["/src/setupTests.ts"], + setupFilesAfterEnv: ["/src/setupTests.tsx"], }; diff --git a/app/lang/de.json b/app/lang/de.json index fd3c122..76d348d 100644 --- a/app/lang/de.json +++ b/app/lang/de.json @@ -27,6 +27,10 @@ "defaultMessage": "Hygiene", "description": "parameter label" }, + "DZHDmU": { + "defaultMessage": "Nachrichten", + "description": "main nav messages page item label" + }, "Ejhsxh": { "defaultMessage": "Ein Fehler ist aufgetreten!", "description": "unknown error snack" @@ -79,6 +83,10 @@ "defaultMessage": "Crew-Moral", "description": "parameter label" }, + "Vdokz/": { + "defaultMessage": "Es gibt noch keine Nachrichten", + "description": "messages page no messages yet title" + }, "WHOfrR": { "defaultMessage": "Setze Charakter", "description": "qr code action label" @@ -107,6 +115,10 @@ "defaultMessage": "Diversity Puzzle Trails", "description": "main page title" }, + "eYC0U9": { + "defaultMessage": "Nachrichten", + "description": "messages page title" + }, "f0maUH": { "defaultMessage": "Um an einem Spiel teilzunehmen ist ein Spiel-Code erforderlich. Bitte tippe diesen hier ein:", "description": "start code page description" @@ -135,6 +147,10 @@ "defaultMessage": "An einem Spiel teilnehmen", "description": "start code page title" }, + "nwAgUn": { + "defaultMessage": "Schau doch später nochmal vorbei.", + "description": "messages page no messages yet description" + }, "qPHCvM": { "defaultMessage": "Spielstand", "description": "global parameter card title" diff --git a/app/src/app/AppRouter.tsx b/app/src/app/AppRouter.tsx index 8c450ba..62b3d96 100644 --- a/app/src/app/AppRouter.tsx +++ b/app/src/app/AppRouter.tsx @@ -4,6 +4,7 @@ import ProgressBar from "@badrap/bar-of-progress"; import useLocation, { BaseLocationHook } from "wouter/use-location"; import useGameId from "../common/hooks/useGameId"; +import config from "../config"; /** * Wrap `React.lazy()` and return the factory as well for preloading the component @@ -17,6 +18,7 @@ const lazyWithPreload = >( const CodePage = lazyWithPreload(() => import("./pages/CodePage")); const IndexPage = lazyWithPreload(() => import("./pages/IndexPage")); +const MessagesPage = lazyWithPreload(() => import("./pages/MessagesPage")); const ScannerPage = lazyWithPreload(() => import("./pages/ScannerPage")); const StartPage = lazyWithPreload(() => import("./pages/StartPage")); @@ -37,6 +39,10 @@ const routeFactories = [ path: "/start/:gameId?", factory: StartPage.factory, }, + { + path: "/messages", + factory: MessagesPage.factory, + }, ]; /** @@ -84,7 +90,13 @@ const AppRouter = () => { {({ gameId }) => } - + {config.featureMessages ? ( + + {gameId ? : } + + ) : ( + <> + )} {/* Redirect to index page on 404 */} diff --git a/app/src/app/MainNav.test.tsx b/app/src/app/MainNav.test.tsx new file mode 100644 index 0000000..fdb936d --- /dev/null +++ b/app/src/app/MainNav.test.tsx @@ -0,0 +1,29 @@ +import { screen } from "@testing-library/react"; +import * as React from "react"; +import userEvent from "@testing-library/user-event"; + +import config from "../config"; +import MainNav from "./MainNav"; +import render from "../common/testing/render"; + +it("renders correctly", () => { + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); +}); + +it("sets the location on click", () => { + render(); + + expect(window.location.pathname).toEqual("/"); + + if (config.featureMessages) { + userEvent.click(screen.getByText(/Nachrichten/)); + expect(window.location.pathname).toEqual("/messages"); + } + + userEvent.click(screen.getByText(/Scanner/)); + expect(window.location.pathname).toEqual("/scan"); + + userEvent.click(screen.getByText(/Status/)); + expect(window.location.pathname).toEqual("/"); +}); diff --git a/app/src/app/MainNav.tsx b/app/src/app/MainNav.tsx index b224644..32a7942 100644 --- a/app/src/app/MainNav.tsx +++ b/app/src/app/MainNav.tsx @@ -1,10 +1,13 @@ -import { Paper, BottomNavigation, BottomNavigationAction } from "@mui/material"; -import { Home } from "@mui/icons-material"; -import * as React from "react"; +import { ChatBubble, Home } from "@mui/icons-material"; import { FormattedMessage } from "react-intl"; +import { Paper, BottomNavigation, BottomNavigationAction } from "@mui/material"; import { useLocation } from "wouter"; +import * as React from "react"; + import QrCodeScanner from "../common/icons/QrCodeScanner"; +import config from "../config"; + type MainNavProps = {}; const MainNav = (props: MainNavProps) => { @@ -47,6 +50,18 @@ const MainNav = (props: MainNavProps) => { } icon={} /> + {config.featureMessages && ( + + } + icon={} + /> + )} ); diff --git a/app/src/app/__snapshots__/MainNav.test.tsx.snap b/app/src/app/__snapshots__/MainNav.test.tsx.snap new file mode 100644 index 0000000..563dc42 --- /dev/null +++ b/app/src/app/__snapshots__/MainNav.test.tsx.snap @@ -0,0 +1,86 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` + +`; diff --git a/app/src/app/pages/MessagesPage.test.tsx b/app/src/app/pages/MessagesPage.test.tsx new file mode 100644 index 0000000..0b7014f --- /dev/null +++ b/app/src/app/pages/MessagesPage.test.tsx @@ -0,0 +1,41 @@ +import { rest } from "msw"; +import { screen, waitFor } from "@testing-library/react"; +import * as React from "react"; + +import { server } from "../../mocks/server"; +import MessagesPage from "./MessagesPage"; +import render from "../../common/testing/render"; + +it("renders a loading screen", () => { + render(); + + expect(screen.getByRole("progressbar")).toBeInTheDocument(); +}); + +it("renders messages", async () => { + const { container } = render(); + + await waitFor(() => expect(screen.getByText(/Test 123/)).toBeInTheDocument()); + expect(container.firstChild).toMatchSnapshot(); +}); + +it("renders no messages yet hero message", async () => { + server.use( + rest.get("/games/:gameId/messages", (req, res, ctx) => { + return res( + ctx.json({ + data: [], + }) + ); + }) + ); + + const { container } = render(); + + await waitFor(() => + expect( + screen.getByText(/Es gibt noch keine Nachrichten/) + ).toBeInTheDocument() + ); + expect(container.firstChild).toMatchSnapshot(); +}); diff --git a/app/src/app/pages/MessagesPage.tsx b/app/src/app/pages/MessagesPage.tsx new file mode 100644 index 0000000..507e814 --- /dev/null +++ b/app/src/app/pages/MessagesPage.tsx @@ -0,0 +1,80 @@ +import { + AppBar, + Toolbar, + Typography, + Box, + Container, + LinearProgress, +} from "@mui/material"; +import { FormattedMessage } from "react-intl"; +import * as React from "react"; + +import MainNav from "../MainNav"; +import MessageCard from "../../common/components/MessageCard"; +import useMessages from "../../common/hooks/api/useMessages"; +import { Message } from "../../common/types/Message"; +import HeroMessage from "../../common/components/HeroMessage"; + +const MessagesPage = () => { + const { data: messages } = useMessages(); + + // Scroll to the bottom after (new) messages are rendered + React.useEffect(() => { + const $html = document.documentElement; + $html.scrollTop = $html.scrollHeight; + }, [messages]); + + /** + * Whether the API has returned zero messages + */ + const hasNoMessages = messages && messages.data.length === 0; + + return ( +
+ + + + + + + + + + {hasNoMessages ? ( + + } + description={ + + } + /> + ) : messages ? ( + + {messages.data.map((msg: Message) => ( + + ))} + + ) : ( + + )} + +
+ ); +}; + +export default MessagesPage; diff --git a/app/src/app/pages/__snapshots__/IndexPage.test.tsx.snap b/app/src/app/pages/__snapshots__/IndexPage.test.tsx.snap index 2cdb0aa..9950943 100644 --- a/app/src/app/pages/__snapshots__/IndexPage.test.tsx.snap +++ b/app/src/app/pages/__snapshots__/IndexPage.test.tsx.snap @@ -93,6 +93,31 @@ exports[`can show the game is over 1`] = ` class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root" /> +
+
+
+
+
+
+

+ Nachrichten +

+
+
+ +
+
+
+
+

+ Irgendeine Nachricht :) +

+ +
+ +
+
+
+
+

+ Test 123 +

+ +
+ +
+
+
+
+ +`; + +exports[`renders no messages yet hero message 1`] = ` +
+
+
+

+ Nachrichten +

+
+
+ +
+
+
+

+ Es gibt noch keine Nachrichten +

+

+ Schau doch später nochmal vorbei. +

+
+
+
+
+`; diff --git a/app/src/common/components/ChooseCharacterHeroMessage.tsx b/app/src/common/components/ChooseCharacterHeroMessage.tsx index 3b92a93..12b16ff 100644 --- a/app/src/common/components/ChooseCharacterHeroMessage.tsx +++ b/app/src/common/components/ChooseCharacterHeroMessage.tsx @@ -4,6 +4,8 @@ import * as React from "react"; import HeroMessage from "./HeroMessage"; +import config from "../../config"; + type ChooseCharacterHeroMessageProps = {}; const ChooseCharacterHeroMessage = (props: ChooseCharacterHeroMessageProps) => { @@ -26,7 +28,7 @@ const ChooseCharacterHeroMessage = (props: ChooseCharacterHeroMessageProps) => { sx={{ marginBottom: 3, marginTop: 3, - transform: "translateX(84px)", // TODO remove when the messages nav entry is available + transform: config.featureMessages ? "" : "translateX(84px)", }} /> } diff --git a/app/src/common/components/MessageCard.test.tsx b/app/src/common/components/MessageCard.test.tsx new file mode 100644 index 0000000..bef92f9 --- /dev/null +++ b/app/src/common/components/MessageCard.test.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import render from "../testing/render"; + +import MessageCard from "./MessageCard"; + +it("renders correctly", () => { + const { container } = render( + + ); + expect(container.firstChild).toMatchSnapshot(); +}); diff --git a/app/src/common/components/MessageCard.tsx b/app/src/common/components/MessageCard.tsx new file mode 100644 index 0000000..f7990c9 --- /dev/null +++ b/app/src/common/components/MessageCard.tsx @@ -0,0 +1,45 @@ +import { Card, CardContent, Typography } from "@mui/material"; +import { FormattedRelativeTime } from "react-intl"; +import * as React from "react"; + +import { Message } from "../types/Message"; + +type MessageCardProps = { + message: Message; +}; + +const MessageCard = ({ message }: MessageCardProps) => { + return ( + + + {message.attributes.message} + theme.spacing(1), + }} + variant="caption" + > + + + + + ); +}; + +export default MessageCard; diff --git a/app/src/common/components/__snapshots__/ChooseCharacterHeroMessage.test.tsx.snap b/app/src/common/components/__snapshots__/ChooseCharacterHeroMessage.test.tsx.snap index fbe9284..b162328 100644 --- a/app/src/common/components/__snapshots__/ChooseCharacterHeroMessage.test.tsx.snap +++ b/app/src/common/components/__snapshots__/ChooseCharacterHeroMessage.test.tsx.snap @@ -19,7 +19,7 @@ exports[`renders correctly 1`] = `

+

+ Test 123 +

+ +
+ +
+
+`; diff --git a/app/src/common/hooks/api/useMessages.test.ts b/app/src/common/hooks/api/useMessages.test.ts new file mode 100644 index 0000000..4a8e5ec --- /dev/null +++ b/app/src/common/hooks/api/useMessages.test.ts @@ -0,0 +1,12 @@ +import { renderHook } from "@testing-library/react-hooks"; + +import useMessages from "./useMessages"; + +it("loads the messages", async () => { + const { result, waitForValueToChange } = renderHook(() => useMessages()); + + await waitForValueToChange(() => result.current.data); + expect(result.current.data).toEqual( + require("../../../mocks/data/messages.json") + ); +}); diff --git a/app/src/common/hooks/api/useMessages.ts b/app/src/common/hooks/api/useMessages.ts new file mode 100644 index 0000000..38696a3 --- /dev/null +++ b/app/src/common/hooks/api/useMessages.ts @@ -0,0 +1,32 @@ +import useSWR from "swr"; + +import { Message } from "../../types/Message"; +import ApiError from "./helper/ApiError"; +import authenticatedFetcher from "./helper/authenticatedFetcher"; +import config from "../../../config"; +import useApiUrl from "./useApiUrl"; +import useInstanceId from "../useInstanceId"; + +interface MessagesApiResponse { + data: Message[]; +} + +/** + * A React hook that continuously retrieves the global messages + * + * @returns The messages. May be null during initial load + */ +const useClock = () => { + const url = useApiUrl((gameId) => config.apiEndpoints.messages(gameId)); + const instanceId = useInstanceId(); + + return useSWR( + () => [url, instanceId], + authenticatedFetcher, + { + refreshInterval: 10 * 1000, + } + ); +}; + +export default useClock; diff --git a/app/src/common/types/Message.ts b/app/src/common/types/Message.ts new file mode 100644 index 0000000..983807b --- /dev/null +++ b/app/src/common/types/Message.ts @@ -0,0 +1,26 @@ +/** + * A single message + */ +export interface Message { + /** + * Object type + */ + type: "message"; + + /** + * Unique message ID + */ + id: string; + + attributes: { + /** + * An ISO-8601 time descriptor of when the message was created + */ + createdAt: string; + + /** + * The message + */ + message: string; + }; +} diff --git a/app/src/config.ts b/app/src/config.ts index 0d55143..ff650e7 100644 --- a/app/src/config.ts +++ b/app/src/config.ts @@ -7,12 +7,18 @@ interface Config { code: (codeId: string, gameId: string) => string; game: (gameId: string) => string; parameters: (gameId: string) => string; + messages: (gameId: string) => string; }; /** * Allowed URL origins for QR codes that are recognized by the scanner */ allowedCodeOrigins: string[]; + + /** + * Whether the messages feature is enabled + */ + featureMessages: boolean; } const config: Config = { @@ -23,8 +29,11 @@ const config: Config = { game: (gameId: string) => `${process.env.API_ROOT}/games/${gameId}`, parameters: (gameId: string) => `${process.env.API_ROOT}/games/${gameId}/parameters`, + messages: (gameId: string) => + `${process.env.API_ROOT}/games/${gameId}/messages`, }, allowedCodeOrigins: [window.location.origin, "https://abc-dpt.netlify.app"], + featureMessages: true, }; export default config; diff --git a/app/src/mocks/data/messages.json b/app/src/mocks/data/messages.json new file mode 100644 index 0000000..adf2c26 --- /dev/null +++ b/app/src/mocks/data/messages.json @@ -0,0 +1,20 @@ +{ + "data": [ + { + "type": "message", + "id": "4PyhXohHPCneobX62WsTr", + "attributes": { + "createdAt": "2021-05-05T12:43:19.728Z", + "message": "Irgendeine Nachricht :)" + } + }, + { + "type": "message", + "id": "sStIdEE8qNoPyTlB7yn84", + "attributes": { + "createdAt": "2021-05-14T12:58:59.063Z", + "message": "Test 123" + } + } + ] +} diff --git a/app/src/mocks/handlers.ts b/app/src/mocks/handlers.ts index c26e2d6..eae5540 100644 --- a/app/src/mocks/handlers.ts +++ b/app/src/mocks/handlers.ts @@ -54,4 +54,8 @@ export const handlers = [ } return res(ctx.json(require("./data/game.json"))); }), + + rest.get("/games/:gameId/messages", (req, res, ctx) => { + return res(ctx.json(require("./data/messages.json"))); + }), ]; diff --git a/app/src/setupTests.ts b/app/src/setupTests.tsx similarity index 73% rename from app/src/setupTests.ts rename to app/src/setupTests.tsx index e8bf9d4..97f0e10 100644 --- a/app/src/setupTests.ts +++ b/app/src/setupTests.tsx @@ -1,6 +1,8 @@ import "@testing-library/jest-dom"; +import * as React from "react"; import fetch from "node-fetch"; +import * as intl from "react-intl"; import * as useGameId from "./common/hooks/useGameId"; import * as useInstanceId from "./common/hooks/useInstanceId"; @@ -9,6 +11,9 @@ import { server } from "./mocks/server"; // @ts-expect-error window.fetch = fetch; +// Mock relative time so tests work at any point in time in the future :) +jest.spyOn(intl, "FormattedRelativeTime").mockReturnValue(
); + jest .spyOn(useGameId, "default") .mockReturnValue(["test-game", () => {}, () => {}]);