diff --git a/tnoodle-server/src/main/kotlin/org/worldcubeassociation/tnoodle/server/model/EventData.kt b/tnoodle-server/src/main/kotlin/org/worldcubeassociation/tnoodle/server/model/EventData.kt index 965565ebe..b28c7b610 100644 --- a/tnoodle-server/src/main/kotlin/org/worldcubeassociation/tnoodle/server/model/EventData.kt +++ b/tnoodle-server/src/main/kotlin/org/worldcubeassociation/tnoodle/server/model/EventData.kt @@ -1,6 +1,6 @@ package org.worldcubeassociation.tnoodle.server.model -enum class EventData(val key: String, val description: String, val scrambler: PuzzleData, val legalFormats: Set) { +enum class EventData(val id: String, val description: String, val scrambler: PuzzleData, val legalFormats: Set) { THREE(PuzzleData.THREE, FormatData.BIG_AVERAGE_FORMATS), TWO(PuzzleData.TWO, FormatData.BIG_AVERAGE_FORMATS), FOUR(PuzzleData.FOUR, FormatData.BIG_AVERAGE_FORMATS), @@ -19,10 +19,10 @@ enum class EventData(val key: String, val description: String, val scrambler: Pu FIVE_BLD("555bf", "5x5x5 Blindfolded", PuzzleData.FIVE_BLD, FormatData.BLD_SPECIAL_FORMATS), THREE_MULTI_BLD("333mbf", "3x3x3 Multiple Blindfolded", PuzzleData.THREE_BLD, FormatData.BLD_SPECIAL_FORMATS); - constructor(scrambler: PuzzleData, legalFormats: Set) : this(scrambler.key, scrambler.description, scrambler, legalFormats) + constructor(scrambler: PuzzleData, legalFormats: Set) : this(scrambler.id, scrambler.description, scrambler, legalFormats) companion object { - val WCA_EVENTS = values().associateBy { it.key }.toSortedMap() + val WCA_EVENTS = values().associateBy { it.id }.toSortedMap() val ONE_HOUR_EVENTS = setOf(THREE_FM, THREE_MULTI_BLD) val ATTEMPT_BASED_EVENTS = setOf(THREE_FM, THREE_MULTI_BLD) diff --git a/tnoodle-server/src/main/kotlin/org/worldcubeassociation/tnoodle/server/model/PuzzleData.kt b/tnoodle-server/src/main/kotlin/org/worldcubeassociation/tnoodle/server/model/PuzzleData.kt index cb36b85e4..53d962d3b 100644 --- a/tnoodle-server/src/main/kotlin/org/worldcubeassociation/tnoodle/server/model/PuzzleData.kt +++ b/tnoodle-server/src/main/kotlin/org/worldcubeassociation/tnoodle/server/model/PuzzleData.kt @@ -3,20 +3,24 @@ package org.worldcubeassociation.tnoodle.server.model import org.worldcubeassociation.tnoodle.scrambles.PuzzleRegistry import org.worldcubeassociation.tnoodle.server.model.cache.CoroutineScrambleCacher -enum class PuzzleData(private val registry: PuzzleRegistry) { +enum class PuzzleData( + private val registry: PuzzleRegistry, + private val parentScrambler: PuzzleData? = null, + val groupId: String? = null +) { // To all fellow programmers who wonder about effectively copying an interface: // 1-- Be able to intercept the `scrambler` reference (see getter below) // 2-- Be able to limit the selection of tnoodle-lib `Puzzle`s that are exposed. - TWO(PuzzleRegistry.TWO), - THREE(PuzzleRegistry.THREE), - FOUR(PuzzleRegistry.FOUR), - FIVE(PuzzleRegistry.FIVE), - SIX(PuzzleRegistry.SIX), - SEVEN(PuzzleRegistry.SEVEN), - THREE_BLD(PuzzleRegistry.THREE_NI), - FOUR_BLD(PuzzleRegistry.FOUR_NI), - FIVE_BLD(PuzzleRegistry.FIVE_NI), - THREE_FMC(PuzzleRegistry.THREE_FM), + TWO(PuzzleRegistry.TWO, groupId = "nbyn"), + THREE(PuzzleRegistry.THREE, groupId = "nbyn"), + FOUR(PuzzleRegistry.FOUR, groupId = "nbyn"), + FIVE(PuzzleRegistry.FIVE, groupId = "nbyn"), + SIX(PuzzleRegistry.SIX, groupId = "nbyn"), + SEVEN(PuzzleRegistry.SEVEN, groupId = "nbyn"), + THREE_BLD(PuzzleRegistry.THREE_NI, THREE, groupId = "nbyn"), + FOUR_BLD(PuzzleRegistry.FOUR_NI, FOUR, groupId = "nbyn"), + FIVE_BLD(PuzzleRegistry.FIVE_NI, FIVE, groupId = "nbyn"), + THREE_FMC(PuzzleRegistry.THREE_FM, THREE, groupId = "nbyn"), PYRA(PuzzleRegistry.PYRA), SQ1(PuzzleRegistry.SQ1), MEGA(PuzzleRegistry.MEGA), @@ -24,15 +28,17 @@ enum class PuzzleData(private val registry: PuzzleRegistry) { SKEWB(PuzzleRegistry.SKEWB); // TODO have tnoodle-lib provide an interface that this stuff can be delegated to - val key get() = registry.key + val id get() = registry.key val description get() = registry.description val scrambler get() = registry.scrambler val scramblerWithCache get() = registry.also { warmUpCache() }.scrambler - val cacheSize get() = SCRAMBLE_CACHERS[this.key]?.available + val cacheSize get() = SCRAMBLE_CACHERS[this.id]?.available + + val rootScrambler: PuzzleData get() = this.parentScrambler?.rootScrambler ?: this fun warmUpCache(cacheSize: Int = CACHE_SIZE) { - SCRAMBLE_CACHERS.getOrPut(this.key) { CoroutineScrambleCacher(this.registry.scrambler, cacheSize) } + SCRAMBLE_CACHERS.getOrPut(this.id) { CoroutineScrambleCacher(this.registry.scrambler, cacheSize) } } fun generateEfficientScrambles(num: Int, action: (String) -> Unit = {}): List { @@ -43,7 +49,11 @@ enum class PuzzleData(private val registry: PuzzleRegistry) { .toList() } - private fun yieldScramble() = SCRAMBLE_CACHERS[this.key] + fun generateScramble(action: (String) -> Unit = {}): String { + return yieldScramble().also(action) + } + + private fun yieldScramble() = SCRAMBLE_CACHERS[this.id] ?.takeIf { it.available > 0 }?.getScramble() ?: this.scramblerWithCache.generateScramble() @@ -52,6 +62,6 @@ enum class PuzzleData(private val registry: PuzzleRegistry) { private val SCRAMBLE_CACHERS = mutableMapOf() - val WCA_PUZZLES = values().associateBy { it.key }.toSortedMap() + val WCA_PUZZLES = values().associateBy { it.id }.toSortedMap() } } diff --git a/tnoodle-ui/package.json b/tnoodle-ui/package.json index 102221f93..a410ba594 100644 --- a/tnoodle-ui/package.json +++ b/tnoodle-ui/package.json @@ -15,7 +15,9 @@ "react-icons": "^4.12.0", "react-redux": "^9.0.4", "react-scripts": "5.0.1", - "web-vitals": "^3.3.2" + "web-vitals": "^3.3.2", + "react-inlinesvg": "^3.0.2", + "react-color": "^2.19.3" }, "scripts": { "start": "react-scripts start", @@ -66,6 +68,7 @@ "@types/node": "^20.6.2", "@types/react": "^18.2.45", "@types/react-dom": "^18.2.17", + "@types/react-color": "^3.0.6", "prettier": "^2.8.8", "typescript": "^4.9.4" } diff --git a/tnoodle-ui/src/main/api/tnoodle.api.ts b/tnoodle-ui/src/main/api/tnoodle.api.ts index a90d42366..ae391f6d6 100644 --- a/tnoodle-ui/src/main/api/tnoodle.api.ts +++ b/tnoodle-ui/src/main/api/tnoodle.api.ts @@ -1,13 +1,12 @@ import axios from "axios"; import BestMbld from "../model/BestMbld"; import RunningVersion from "../model/RunningVersion"; -import Translation from "../model/Translation"; import WcaEvent from "../model/WcaEvent"; import WcaFormat from "../model/WcaFormat"; import Wcif from "../model/Wcif"; import { ScrambleClient } from "./tnoodle.socket"; import WebsocketBlobResult from "../model/WebsocketBlobResult"; -import FrontendStatus from "../model/FrontendStatus"; +import ScrambleAndImage from "../model/ScrambleAndImage"; let backendUrl = new URL("http://localhost:2014"); export const tNoodleBackend = backendUrl.toString().replace(/\/$/g, ""); @@ -17,24 +16,15 @@ let versionEndpoint = "/version"; let fmcTranslationsEndpoint = "/frontend/fmc/languages/available"; let suggestedFmcTranslationsEndpoint = "/frontend/fmc/languages/competitors"; let bestMbldAttemptEndpoint = "/frontend/mbld/best"; +let puzzleColorSchemeEndpoint = (puzzleId: string) => + `/frontend/puzzle/${puzzleId}/colors`; +let puzzleRandomScrambleEndpoint = (puzzleId: string) => + `/frontend/puzzle/${puzzleId}/scramble`; +let solvedPuzzleSvgEndpoint = (puzzleId: string) => + `/frontend/puzzle/${puzzleId}/svg`; let wcaEventsEndpoint = "/frontend/data/events"; let formatsEndpoint = "/frontend/data/formats"; -/** - * Builds the object expected for FMC translations - * @param {array} translations e.g. ["de", "da", "pt-BR"] - */ -const fmcTranslationsHelper = (translations?: Translation[]) => { - if (!translations) { - return null; - } - return { - languageTags: translations - .filter((translation) => translation.status) - .map((translation) => translation.id), - }; -}; - class TnoodleApi { fetchWcaEvents = () => axios.get(tNoodleBackend + wcaEventsEndpoint); @@ -51,6 +41,29 @@ class TnoodleApi { fetchBestMbldAttempt = (wcif: Wcif) => axios.post(tNoodleBackend + bestMbldAttemptEndpoint, wcif); + fetchPuzzleColorScheme = (puzzleId: string) => + axios.get>( + tNoodleBackend + puzzleColorSchemeEndpoint(puzzleId) + ); + + fetchPuzzleRandomScramble = ( + puzzleId: string, + colorScheme: Record = {} + ) => + axios.post( + tNoodleBackend + puzzleRandomScrambleEndpoint(puzzleId), + colorScheme + ); + + fetchPuzzleSolvedSvg = ( + puzzleId: string, + colorScheme: Record = {} + ) => + axios.post( + tNoodleBackend + solvedPuzzleSvgEndpoint(puzzleId), + colorScheme + ); + fetchRunningVersion = () => axios.get(tNoodleBackend + versionEndpoint); @@ -62,17 +75,11 @@ class TnoodleApi { fetchZip = ( scrambleClient: ScrambleClient, wcif: Wcif, - mbld: string, - password: string, - status: FrontendStatus, - translations?: Translation[] + password: string ) => { let payload = { wcif, - multiCubes: { requestedScrambles: mbld }, - fmcLanguages: fmcTranslationsHelper(translations), zipPassword: !password ? null : password, - frontendStatus: status, }; return scrambleClient.loadScrambles(zipEndpoint, payload, wcif.id); diff --git a/tnoodle-ui/src/main/components/EventPicker.css b/tnoodle-ui/src/main/components/EventPicker.css index f7a34727c..c17fd901f 100644 --- a/tnoodle-ui/src/main/components/EventPicker.css +++ b/tnoodle-ui/src/main/components/EventPicker.css @@ -13,3 +13,7 @@ .cubing-icon { font-size: 6em; } + +.fit-content .tooltip-inner { + max-width: fit-content; +} diff --git a/tnoodle-ui/src/main/components/EventPicker.tsx b/tnoodle-ui/src/main/components/EventPicker.tsx index 9ae6ad878..52942903f 100644 --- a/tnoodle-ui/src/main/components/EventPicker.tsx +++ b/tnoodle-ui/src/main/components/EventPicker.tsx @@ -1,4 +1,4 @@ -import { ProgressBar } from "react-bootstrap"; +import { OverlayTrigger, ProgressBar, Tooltip } from "react-bootstrap"; import { useDispatch, useSelector } from "react-redux"; import { MAX_WCA_ROUNDS } from "../constants/wca.constants"; import RootState from "../model/RootState"; @@ -6,25 +6,39 @@ import Round from "../model/Round"; import WcaEvent from "../model/WcaEvent"; import WcifEvent from "../model/WcifEvent"; import { setFileZip } from "../redux/slice/ScramblingSlice"; -import { setWcaEvent } from "../redux/slice/WcifSlice"; +import { setWcifEvent } from "../redux/slice/WcifSlice"; import { + colorSchemeExtensionId, copiesExtensionId, getDefaultCopiesExtension, } from "../util/wcif.util"; +import tnoodleApi from "../api/tnoodle.api"; import "./EventPicker.css"; import FmcTranslationsDetail from "./FmcTranslationsDetail"; import MbldDetail from "./MbldDetail"; import "@cubing/icons"; +import { useCallback, useEffect, useState } from "react"; +import SVG from "react-inlinesvg"; +import SchemeColorPicker from "./SchemeColorPicker"; +import ScrambleAndImage from "../model/ScrambleAndImage"; +import _ from "lodash"; +import { setExtensionLazily, findExtension } from "../util/extension.util"; interface EventPickerProps { wcaEvent: WcaEvent; - wcifEvent?: WcifEvent; + wcifEvent: WcifEvent; } const EventPicker = ({ wcaEvent, wcifEvent }: EventPickerProps) => { const wcaFormats = useSelector( (state: RootState) => state.wcifSlice.wcaFormats ); + const wcaEvents = useSelector( + (state: RootState) => state.wcifSlice.wcaEvents + ); + const wcifEvents = useSelector( + (state: RootState) => state.wcifSlice.wcif.events + ); const editingStatus = useSelector( (state: RootState) => state.wcifSlice.editingStatus ); @@ -38,12 +52,111 @@ const EventPicker = ({ wcaEvent, wcifEvent }: EventPickerProps) => { (state: RootState) => state.scramblingSlice.scramblingProgressTarget ); + const [puzzleSvg, setPuzzleSvg] = useState(); + const [randomSampleScramble, setRandomSampleScramble] = + useState(); + + const [defaultColorScheme, setDefaultColorScheme] = + useState>(); + const [colorScheme, setColorScheme] = useState>(); + + const [showColorSchemeConfig, setShowColorSchemeConfig] = + useState(false); + + const fetchDisplayScramble = useCallback( + (fetchNewScramble = true) => { + // we need this additional boolean to make sure we can fetch only once + // when the overlay is hidden, because its callback yields a `show` boolean. + if (fetchNewScramble && colorScheme !== undefined) { + tnoodleApi + .fetchPuzzleRandomScramble(wcaEvent.puzzle_id, colorScheme) + .then((response) => { + setRandomSampleScramble(response.data); + }); + } + }, + [wcaEvent.puzzle_id, colorScheme] + ); + + useEffect(() => { + if (colorScheme !== undefined) { + tnoodleApi + .fetchPuzzleSolvedSvg(wcaEvent.puzzle_id, colorScheme) + .then((response) => { + setPuzzleSvg(response.data); + }); + + fetchDisplayScramble(); + } + }, [wcaEvent.puzzle_id, colorScheme, fetchDisplayScramble]); + + useEffect(() => { + let colorSchemeExtension = findExtension( + wcifEvent, + colorSchemeExtensionId + ); + + if (colorSchemeExtension !== undefined) { + setColorScheme(colorSchemeExtension.data.colorScheme); + } else if (defaultColorScheme !== undefined) { + setColorScheme(defaultColorScheme); + } + }, [wcifEvent, defaultColorScheme]); + const dispatch = useDispatch(); - const updateEvent = (rounds: Round[]) => { - let event = { id: wcaEvent.id, rounds }; - dispatch(setFileZip()); - dispatch(setWcaEvent(event)); + useEffect(() => { + if (wcifEvent.rounds.length > 0) { + if (defaultColorScheme === undefined) { + tnoodleApi + .fetchPuzzleColorScheme(wcaEvent.puzzle_id) + .then((response) => { + setDefaultColorScheme(response.data); + }); + } + } + }, [wcaEvent.puzzle_id, wcifEvent.rounds, defaultColorScheme]); + + const dispatchWcifEvent = useCallback( + (newWcifEvent: WcifEvent) => { + dispatch(setWcifEvent(newWcifEvent)); + dispatch(setFileZip()); + }, + [dispatch] + ); + + const updateEventRounds = (rounds: Round[]) => { + let event = { + ...wcifEvent, + rounds, + }; + + dispatchWcifEvent(event); + }; + + const updateEventColorScheme = (colorScheme: Record) => { + setExtensionLazily( + wcifEvent, + colorSchemeExtensionId, + () => { + return buildColorSchemeExtension(colorScheme); + }, + dispatchWcifEvent + ); + }; + + const buildColorSchemeExtension = (colorScheme: Record) => { + let isDefaultColorScheme = _.isEqual(colorScheme, defaultColorScheme); + + if (isDefaultColorScheme) { + return null; + } + + return { + id: colorSchemeExtensionId, + specUrl: "", + data: { colorScheme }, + }; }; const handleNumberOfRoundsChange = ( @@ -65,7 +178,11 @@ const EventPicker = ({ wcaEvent, wcifEvent }: EventPickerProps) => { extensions: [getDefaultCopiesExtension()], }); } - updateEvent(newRounds); + updateEventRounds(newRounds); + + if (numberOfRounds === 0) { + setShowColorSchemeConfig(false); + } }; const handleGeneralRoundChange = ( @@ -74,7 +191,7 @@ const EventPicker = ({ wcaEvent, wcifEvent }: EventPickerProps) => { rounds: Round[], name: "format" | "scrambleSetCount" ) => { - updateEvent( + updateEventRounds( rounds.map((round, i) => i !== roundNumber ? round : { ...round, [name]: value } ) @@ -86,7 +203,7 @@ const EventPicker = ({ wcaEvent, wcifEvent }: EventPickerProps) => { numCopies: string, rounds: Round[] ) => { - updateEvent( + updateEventRounds( rounds.map((round, i) => i !== roundNumber ? round @@ -102,12 +219,146 @@ const EventPicker = ({ wcaEvent, wcifEvent }: EventPickerProps) => { ); }; + const handleColorSchemeChange = (colorKey: string, hexColor: string) => { + let newColorScheme = { + ...colorScheme, + [colorKey]: hexColor, + }; + + updateEventColorScheme(newColorScheme); + }; + const abbreviate = (str: string) => !!wcaFormats ? wcaFormats[str].shortName : "-"; - const maybeShowTableTitles = (rounds: Round[]) => { - if (rounds.length === 0) { - return null; + const updateForeignColorScheme = (wcaEvent: WcaEvent) => { + let wcifEvent = wcifEvents.find( + (wcifEvent) => wcifEvent.id === wcaEvent.id + ); + + if (wcifEvent !== undefined && colorScheme !== undefined) { + setExtensionLazily( + wcifEvent, + colorSchemeExtensionId, + () => { + return buildColorSchemeExtension(colorScheme); + }, + dispatchWcifEvent + ); + } + }; + + const maybeShowColorPicker = () => { + if (!showColorSchemeConfig || wcifEvent.rounds.length === 0) { + return; + } + + if (colorScheme === undefined || defaultColorScheme === undefined) { + return; + } + + const defaultColors = _.uniq(Object.values(defaultColorScheme)); + + const samePuzzleEvents = + wcaEvents?.filter((event) => { + return ( + event.id !== wcaEvent.id && + event.puzzle_id === wcaEvent.puzzle_id + ); + }) ?? []; + + const samePuzzleGroupEvents = + wcaEvents?.filter((event) => { + return ( + wcaEvent.puzzle_group_id !== null && + event.id !== wcaEvent.id && + event.puzzle_group_id === wcaEvent.puzzle_group_id + ); + }) ?? []; + + return ( + + + + + + {Object.keys(colorScheme).map((colorKey) => { + return ( + + ); + })} + + + + + +
+ + handleColorSchemeChange( + colorKey, + hexColor + ) + } + /> +
+ + {samePuzzleEvents.length > 0 && ( + + )} + {samePuzzleGroupEvents.length > 0 && ( + + )} +
+ + + ); + }; + + const maybeShowTableTitles = () => { + if (wcifEvent.rounds.length === 0) { + return; } return ( @@ -119,15 +370,17 @@ const EventPicker = ({ wcaEvent, wcifEvent }: EventPickerProps) => { ); }; - const maybeShowTableBody = (rounds: Round[]) => { - if (rounds.length === 0) { + const maybeShowTableBody = () => { + if (wcifEvent.rounds.length === 0) { return; } + let wcifRounds = wcifEvent.rounds; + return ( - {Array.from({ length: rounds.length }, (_, i) => { - let copies = rounds[i].extensions.find( + {Array.from({ length: wcifRounds.length }, (_, i) => { + let copies = wcifRounds[i].extensions.find( (extension) => extension.id === copiesExtensionId )?.data.numCopies; return ( @@ -138,12 +391,12 @@ const EventPicker = ({ wcaEvent, wcifEvent }: EventPickerProps) => { handleGeneralRoundChange( i, evt.target.value, - rounds, + wcifRounds, "scrambleSetCount" ) } @@ -187,7 +440,7 @@ const EventPicker = ({ wcaEvent, wcifEvent }: EventPickerProps) => { handleNumberOfCopiesChange( i, evt.target.value, - rounds + wcifRounds ) } min={1} @@ -202,16 +455,15 @@ const EventPicker = ({ wcaEvent, wcifEvent }: EventPickerProps) => { ); }; - const maybeShowProgressBar = (rounds: Round[]) => { - let eventId = wcaEvent.id; + const maybeShowProgressBar = () => { + let target = scramblingProgressTarget[wcaEvent.id]; - let current = scramblingProgressCurrent[eventId] || 0; - let target = scramblingProgressTarget[eventId]; - - if (rounds.length === 0 || !generatingScrambles || !target) { - return; + if (wcifEvent.rounds.length === 0 || !generatingScrambles || !target) { + return null; } + let current = scramblingProgressCurrent[wcaEvent.id] || 0; + let progress = (current / target) * 100; let miniThreshold = 2; @@ -233,14 +485,12 @@ const EventPicker = ({ wcaEvent, wcifEvent }: EventPickerProps) => { ); }; - let rounds = wcifEvent?.rounds || []; - return ( { - {maybeShowTableTitles(rounds)} + {maybeShowColorPicker()} + {maybeShowTableTitles()} - {maybeShowTableBody(rounds)} - {wcaEvent.is_multiple_blindfolded && rounds.length > 0 && ( - - )} - {wcaEvent.is_fewest_moves && rounds.length > 0 && ( - + {maybeShowTableBody()} + {wcaEvent.is_multiple_blindfolded && + wcifEvent.rounds.length > 0 && ( + + )} + {wcaEvent.is_fewest_moves && wcifEvent.rounds.length > 0 && ( + )}
{wcaEvent.name}
- {maybeShowProgressBar(rounds)} + {maybeShowProgressBar()}
+ {wcifEvent.rounds.length > 0 && + puzzleSvg !== undefined && + randomSampleScramble !== undefined && ( +
+ + +

+ (click small preview to + edit) +

+ + } + > + + setShowColorSchemeConfig( + !showColorSchemeConfig + ) + } + /> +
+
+ )}
); diff --git a/tnoodle-ui/src/main/components/EventPickerTable.tsx b/tnoodle-ui/src/main/components/EventPickerTable.tsx index b9230a255..5ff1a4848 100644 --- a/tnoodle-ui/src/main/components/EventPickerTable.tsx +++ b/tnoodle-ui/src/main/components/EventPickerTable.tsx @@ -1,10 +1,9 @@ import { chunk } from "lodash"; -import { useCallback, useEffect } from "react"; +import { useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; import tnoodleApi from "../api/tnoodle.api"; import { toWcaUrl } from "../api/wca.api"; import RootState from "../model/RootState"; -import { setTranslations } from "../redux/slice/FmcSlice"; import { setWcaEvents, setWcaFormats } from "../redux/slice/WcifSlice"; import EventPicker from "./EventPicker"; @@ -24,30 +23,14 @@ const EventPickerTable = () => { const dispatch = useDispatch(); - const getFmcTranslations = useCallback(() => { - tnoodleApi.fetchAvailableFmcTranslations().then((response) => { - const translations = Object.entries(response.data).map( - ([id, name]) => ({ - id, - name, - status: true, - }) - ); - dispatch(setTranslations(translations)); - }); - }, [dispatch]); - - const fetchInformation = () => { + useEffect(() => { tnoodleApi.fetchFormats().then((response) => { dispatch(setWcaFormats(response.data)); }); - tnoodleApi - .fetchWcaEvents() - .then((response) => dispatch(setWcaEvents(response.data))); - getFmcTranslations(); - }; - - useEffect(fetchInformation, [dispatch, getFmcTranslations]); + tnoodleApi.fetchWcaEvents().then((response) => { + dispatch(setWcaEvents(response.data)); + }); + }, [dispatch]); const maybeShowEditWarning = () => { if (!competitionId) { @@ -101,13 +84,23 @@ const EventPickerTable = () => { return (
{chunk.map((wcaEvent) => { + let wcifEvent = wcif.events.find( + (item) => item.id === wcaEvent.id + ); + + if (wcifEvent === undefined) { + wcifEvent = { + id: wcaEvent.id, + rounds: [], + extensions: [], + }; + } + return (
item.id === wcaEvent.id - )} + wcifEvent={wcifEvent} />
); diff --git a/tnoodle-ui/src/main/components/FmcTranslationsDetail.tsx b/tnoodle-ui/src/main/components/FmcTranslationsDetail.tsx index 5af8c1557..78065dd44 100644 --- a/tnoodle-ui/src/main/components/FmcTranslationsDetail.tsx +++ b/tnoodle-ui/src/main/components/FmcTranslationsDetail.tsx @@ -1,59 +1,137 @@ import { chunk } from "lodash"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import RootState from "../model/RootState"; -import { - filterSuggestedFmcTranslations, - updateAllTranslationsStatus, - updateTranslationStatus, -} from "../redux/slice/FmcSlice"; import { setFileZip } from "../redux/slice/ScramblingSlice"; import "./FmcTranslationsDetail.css"; +import tnoodleApi from "../api/tnoodle.api"; +import WcifEvent from "../model/WcifEvent"; +import { fmcTranslationsExtensionId } from "../util/wcif.util"; +import { setWcifEvent } from "../redux/slice/WcifSlice"; +import { + findAndProcessExtension, + setExtensionLazily, +} from "../util/extension.util"; const TRANSLATIONS_PER_LINE = 3; -const FmcTranslationsDetail = () => { - const [showTranslations, setShowTranslations] = useState(false); +interface FmcTranslationsDetailProps { + fmcWcifEvent: WcifEvent; +} +const FmcTranslationsDetail = ({ + fmcWcifEvent, +}: FmcTranslationsDetailProps) => { const suggestedFmcTranslations = useSelector( - (state: RootState) => state.fmcSlice.suggestedFmcTranslations - ); - - const translations = useSelector( - (state: RootState) => state.fmcSlice.translations + (state: RootState) => state.eventDataSlice.suggestedFmcTranslations ); const generatingScrambles = useSelector( (state: RootState) => state.scramblingSlice.generatingScrambles ); + const [availableTranslations, setAvailableTranslations] = + useState>(); + const [selectedTranslations, setSelectedTranslations] = useState( + [] + ); + + const [showTranslations, setShowTranslations] = useState(false); + + useEffect(() => { + findAndProcessExtension( + fmcWcifEvent, + fmcTranslationsExtensionId, + (ext) => { + setSelectedTranslations(ext.data.languageTags); + } + ); + }, [fmcWcifEvent]); + const dispatch = useDispatch(); + useEffect(() => { + if (availableTranslations === undefined) { + tnoodleApi.fetchAvailableFmcTranslations().then((response) => { + setAvailableTranslations(response.data); + }); + } + }, [dispatch, availableTranslations]); + + const buildFmcExtension = (selectedTranslations: string[]) => { + if (selectedTranslations.length === 0) { + return null; + } + + return { + id: fmcTranslationsExtensionId, + specUrl: "", + data: { languageTags: selectedTranslations }, + }; + }; + + const updateEventSelectedTranslations = ( + selectedTranslations: string[] + ) => { + setExtensionLazily( + fmcWcifEvent, + fmcTranslationsExtensionId, + () => { + return buildFmcExtension(selectedTranslations); + }, + (fmcWcifEvent) => { + dispatch(setWcifEvent(fmcWcifEvent)); + dispatch(setFileZip()); + } + ); + }; + const handleTranslation = (id: string, status: boolean) => { - dispatch(setFileZip()); - dispatch(updateTranslationStatus({ id, status })); + let newSelectedTranslations = selectedTranslations.filter( + (it) => it !== id || status + ); + + if (status && !newSelectedTranslations.includes(id)) { + newSelectedTranslations.push(id); + } + + updateEventSelectedTranslations(newSelectedTranslations); }; const handleSelectAllTranslations = () => { - dispatch(setFileZip()); - dispatch(updateAllTranslationsStatus(true)); + if (availableTranslations === undefined) { + selectNoneTranslation(); + } else { + updateEventSelectedTranslations(Object.keys(availableTranslations)); + } }; const selectNoneTranslation = () => { - dispatch(setFileZip()); - dispatch(updateAllTranslationsStatus(false)); + updateEventSelectedTranslations([]); }; const selectSuggestedTranslations = () => { - dispatch(setFileZip()); - dispatch(filterSuggestedFmcTranslations(suggestedFmcTranslations)); + updateEventSelectedTranslations(suggestedFmcTranslations || []); }; const translationsDetail = () => { - let translationsChunks = chunk(translations, TRANSLATIONS_PER_LINE); + if (availableTranslations === undefined) { + return; + } + + let availableTranslationKeys = Object.keys(availableTranslations); + + let translationsChunks = chunk( + availableTranslationKeys, + TRANSLATIONS_PER_LINE + ); + return translationsChunks.map((translationsChunk, i) => ( {translationsChunk.map((translation, j) => { - let checkboxId = `fmc-${translation.id}`; + let checkboxId = `fmc-${translation}`; + let translationStatus = + selectedTranslations.includes(translation); + return ( @@ -61,7 +139,7 @@ const FmcTranslationsDetail = () => { className="fmc-label" htmlFor={checkboxId} > - {translation.name} + {availableTranslations[translation]} @@ -69,10 +147,10 @@ const FmcTranslationsDetail = () => { disabled={generatingScrambles} type="checkbox" id={checkboxId} - checked={translation.status} + checked={translationStatus} onChange={(e) => handleTranslation( - translation.id, + translation, e.target.checked ) } @@ -86,9 +164,6 @@ const FmcTranslationsDetail = () => { )); }; - if (!translations) { - return null; - } return ( diff --git a/tnoodle-ui/src/main/components/Main.tsx b/tnoodle-ui/src/main/components/Main.tsx index fdc8ef683..af9593fe0 100644 --- a/tnoodle-ui/src/main/components/Main.tsx +++ b/tnoodle-ui/src/main/components/Main.tsx @@ -17,16 +17,13 @@ import Interceptor from "./Interceptor"; import "./Main.css"; import VersionInfo from "./VersionInfo"; import WebsocketBlobResult from "../model/WebsocketBlobResult"; +import { frontendStatusExtensionId } from "../util/wcif.util"; const Main = () => { const [competitionNameFileZip, setCompetitionNameFileZip] = useState(""); - const mbld = useSelector((state: RootState) => state.mbldSlice.mbld); const password = useSelector( (state: RootState) => state.scramblingSlice.password ); - const translations = useSelector( - (state: RootState) => state.fmcSlice.translations - ); const wcif = useSelector((state: RootState) => state.wcifSlice.wcif); const competitionId = useSelector( (state: RootState) => state.competitionSlice.competitionId @@ -34,12 +31,6 @@ const Main = () => { const generatingScrambles = useSelector( (state: RootState) => state.scramblingSlice.generatingScrambles ); - const isValidSignedBuild = useSelector( - (state: RootState) => state.scramblingSlice.isValidSignedBuild - ); - const isAllowedVersion = useSelector( - (state: RootState) => state.scramblingSlice.isAllowedVersion - ); const fileZip = useSelector( (state: RootState) => state.scramblingSlice.fileZip ); @@ -76,22 +67,8 @@ const Main = () => { onScrambleProgress ); - let frontendStatus = { - isStaging: isUsingStaging(), - isManual: competitionId == null, - isSignedBuild: isValidSignedBuild, - isAllowedVersion: isAllowedVersion, - }; - tnoodleApi - .fetchZip( - scrambleClient, - wcif, - mbld, - password, - frontendStatus, - translations - ) + .fetchZip(scrambleClient, wcif, password) .then((plainZip: WebsocketBlobResult) => dispatch(setFileZip(plainZip)) ) @@ -107,6 +84,12 @@ const Main = () => { // We use the unofficialZip to stamp .zip in order to prevent delegates / organizers mistakes. // If TNoodle version is not official (as per VersionInfo) or if we generate scrambles using // a competition from staging, add a [Unofficial] + let frontendStatusExtension = wcif.extensions.find( + (ext) => ext.id === frontendStatusExtensionId + ); + + let isValidSignedBuild = frontendStatusExtension?.data.isSignedBuild; + let isAllowedVersion = frontendStatusExtension?.data.isAllowedVersion; let officialZipStatus = isValidSignedBuild && isAllowedVersion; diff --git a/tnoodle-ui/src/main/components/MbldDetail.tsx b/tnoodle-ui/src/main/components/MbldDetail.tsx index e1b0278ca..798adf89c 100644 --- a/tnoodle-ui/src/main/components/MbldDetail.tsx +++ b/tnoodle-ui/src/main/components/MbldDetail.tsx @@ -1,23 +1,58 @@ import { useDispatch, useSelector } from "react-redux"; -import { MBLD_MIN } from "../constants/wca.constants"; +import { MBLD_DEFAULT, MBLD_MIN } from "../constants/wca.constants"; import RootState from "../model/RootState"; -import { setMbld } from "../redux/slice/MbldSlice"; +import { setWcifEvent } from "../redux/slice/WcifSlice"; import { setFileZip } from "../redux/slice/ScramblingSlice"; +import WcifEvent from "../model/WcifEvent"; +import { mbldCubesExtensionId } from "../util/wcif.util"; +import { useEffect, useState } from "react"; +import { + findAndProcessExtension, + setExtensionLazily, +} from "../util/extension.util"; -const MbldDetail = () => { - const mbld = useSelector((state: RootState) => state.mbldSlice.mbld); +interface MbldDetailProps { + mbldWcifEvent: WcifEvent; +} + +const MbldDetail = ({ mbldWcifEvent }: MbldDetailProps) => { const bestMbldAttempt = useSelector( - (state: RootState) => state.mbldSlice.bestMbldAttempt + (state: RootState) => state.eventDataSlice.bestMbldAttempt ); const generatingScrambles = useSelector( (state: RootState) => state.scramblingSlice.generatingScrambles ); + const [mbld, setMbld] = useState(String(MBLD_DEFAULT)); + + useEffect(() => { + findAndProcessExtension(mbldWcifEvent, mbldCubesExtensionId, (ext) => { + setMbld(ext.data.requestedScrambles); + }); + }, [mbldWcifEvent]); + const dispatch = useDispatch(); - const handleMbldChange = (newMbld: string) => { - dispatch(setMbld(newMbld)); - dispatch(setFileZip()); + const buildMbldExtension = (mbld: string) => { + return { + id: mbldCubesExtensionId, + specUrl: "", + data: { requestedScrambles: mbld }, + }; + }; + + const updateEventMbld = (mbld: string) => { + setExtensionLazily( + mbldWcifEvent, + mbldCubesExtensionId, + () => { + return buildMbldExtension(mbld); + }, + (mbldWcifEvent) => { + dispatch(setWcifEvent(mbldWcifEvent)); + dispatch(setFileZip()); + } + ); }; return ( @@ -31,7 +66,7 @@ const MbldDetail = () => { className="form-control bg-dark text-white" type="number" value={mbld} - onChange={(e) => handleMbldChange(e.target.value)} + onChange={(e) => updateEventMbld(e.target.value)} min={MBLD_MIN} required disabled={generatingScrambles} diff --git a/tnoodle-ui/src/main/components/SchemeColorPicker.css b/tnoodle-ui/src/main/components/SchemeColorPicker.css new file mode 100644 index 000000000..9b07a552c --- /dev/null +++ b/tnoodle-ui/src/main/components/SchemeColorPicker.css @@ -0,0 +1,8 @@ +.color-bubble { + padding: 10px; + border-radius: 5px; +} + +.bg-transparent .tooltip-inner { + background-color: transparent; +} diff --git a/tnoodle-ui/src/main/components/SchemeColorPicker.tsx b/tnoodle-ui/src/main/components/SchemeColorPicker.tsx new file mode 100644 index 000000000..0d1698eff --- /dev/null +++ b/tnoodle-ui/src/main/components/SchemeColorPicker.tsx @@ -0,0 +1,48 @@ +import "./SchemeColorPicker.css"; +import { ColorResult, SketchPicker } from "react-color"; +import { OverlayTrigger, Tooltip } from "react-bootstrap"; + +interface SchemeColorPickerProps { + defaultColors: string[]; + colorKey: string; + colorValue: string; + onColorChange(hexColor: string): void; +} + +const SchemeColorPicker = ({ + defaultColors, + colorKey, + colorValue, + onColorChange, +}: SchemeColorPickerProps) => { + const handleColorChange = (color: ColorResult) => { + onColorChange(color.hex); + }; + + return ( + + + + } + > + + {colorKey} + + + ); +}; + +export default SchemeColorPicker; diff --git a/tnoodle-ui/src/main/components/SideBar.tsx b/tnoodle-ui/src/main/components/SideBar.tsx index 655dae335..bf7ec6587 100644 --- a/tnoodle-ui/src/main/components/SideBar.tsx +++ b/tnoodle-ui/src/main/components/SideBar.tsx @@ -9,9 +9,11 @@ import { setCompetitionId, setCompetitions, } from "../redux/slice/CompetitionSlice"; -import { setSuggestedFmcTranslations } from "../redux/slice/FmcSlice"; import { addCachedObject, setMe } from "../redux/slice/InformationSlice"; -import { setBestMbldAttempt } from "../redux/slice/MbldSlice"; +import { + setBestMbldAttempt, + setSuggestedFmcTranslations, +} from "../redux/slice/EventDataSlice"; import { setFileZip } from "../redux/slice/ScramblingSlice"; import { setCompetitionName, @@ -44,10 +46,11 @@ const SideBar = () => { const generatingScrambles = useSelector( (state: RootState) => state.scramblingSlice.generatingScrambles ); - const [isOpen, setIsOpen] = useState(true); const dispatch = useDispatch(); + const [isOpen, setIsOpen] = useState(true); + const handleIsOpen = () => setIsOpen(window.innerWidth > 992); const init = () => { @@ -56,6 +59,7 @@ const SideBar = () => { if (!wcaApi.isLogged()) { return; } + if (!me) { setLoadingUser(true); wcaApi diff --git a/tnoodle-ui/src/main/components/VersionInfo.tsx b/tnoodle-ui/src/main/components/VersionInfo.tsx index d1224049a..94157e81d 100644 --- a/tnoodle-ui/src/main/components/VersionInfo.tsx +++ b/tnoodle-ui/src/main/components/VersionInfo.tsx @@ -1,30 +1,33 @@ -import { useEffect, useState } from "react"; -import { useDispatch } from "react-redux"; +import { useCallback, useEffect, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; import tnoodleApi from "../api/tnoodle.api"; -import wcaApi from "../api/wca.api"; +import wcaApi, { isUsingStaging } from "../api/wca.api"; import CurrentTnoodle from "../model/CurrentTnoodle"; -import { - setAllowedVersion, - setValidSignedBuild, -} from "../redux/slice/ScramblingSlice"; +import { setWcif } from "../redux/slice/WcifSlice"; +import { frontendStatusExtensionId } from "../util/wcif.util"; +import RootState from "../model/RootState"; +import { setExtensionLazily } from "../util/extension.util"; const VersionInfo = () => { + const wcif = useSelector((state: RootState) => state.wcifSlice.wcif); + const competitionId = useSelector( + (state: RootState) => state.competitionSlice.competitionId + ); + + // WCA API response const [currentTnoodle, setCurrentTnoodle] = useState(); const [allowedTnoodleVersions, setAllowedTnoodleVersions] = useState(); + const [wcaPublicKeyBytes, setWcaPublicKeyBytes] = useState(); + + // TNoodle backend API response const [runningVersion, setRunningVersion] = useState(); const [signedBuild, setSignedBuild] = useState(); const [signatureKeyBytes, setSignatureKeyBytes] = useState(); - const [wcaPublicKeyBytes, setWcaPublicKeyBytes] = useState(); - const [signatureValid, setSignatureValid] = useState(true); - - useEffect( - () => - setSignatureValid( - !!signedBuild && signatureKeyBytes === wcaPublicKeyBytes - ), - [signedBuild, signatureKeyBytes, wcaPublicKeyBytes] - ); + + // what we make of it + const [signatureValid, setSignatureValid] = useState(false); + const [versionAllowed, setVersionAllowed] = useState(false); const dispatch = useDispatch(); @@ -37,49 +40,80 @@ const VersionInfo = () => { }); tnoodleApi.fetchRunningVersion().then((response) => { - setRunningVersion( + let versionString = !!response.data.projectName && !!response.data.projectVersion ? `${response.data.projectName}-${response.data.projectVersion}` - : "" - ); + : ""; + + setRunningVersion(versionString); setSignedBuild(response.data.signedBuild); setSignatureKeyBytes(response.data.signatureKeyBytes); }); }, [dispatch]); - // This avoids global state update while rendering - const analyzeVersion = () => { - // We wait until both wca and tnoodle answers - if (!allowedTnoodleVersions || !runningVersion) { + useEffect(() => { + if ( + signedBuild === undefined || + signatureKeyBytes === undefined || + wcaPublicKeyBytes === undefined + ) { return; } - dispatch( - setAllowedVersion(allowedTnoodleVersions.includes(runningVersion)) + setSignatureValid( + signedBuild && signatureKeyBytes === wcaPublicKeyBytes + ); + }, [signedBuild, signatureKeyBytes, wcaPublicKeyBytes]); + + useEffect(() => { + if ( + allowedTnoodleVersions === undefined || + runningVersion === undefined + ) { + return; + } + + setVersionAllowed(allowedTnoodleVersions.includes(runningVersion)); + }, [allowedTnoodleVersions, runningVersion]); + + const buildFrontendStatusExtension = useCallback(() => { + return { + id: frontendStatusExtensionId, + specUrl: "", + data: { + isStaging: isUsingStaging(), + isManual: competitionId == null, + isSignedBuild: signatureValid, + isAllowedVersion: versionAllowed, + }, + }; + }, [competitionId, signatureValid, versionAllowed]); + + useEffect(() => { + setExtensionLazily( + wcif, + frontendStatusExtensionId, + buildFrontendStatusExtension, + (wcif) => { + dispatch(setWcif(wcif)); + } ); - dispatch(setValidSignedBuild(signatureValid)); - }; - useEffect(analyzeVersion, [ - allowedTnoodleVersions, - dispatch, - runningVersion, - signatureValid, - ]); + }, [dispatch, wcif, buildFrontendStatusExtension]); // We cannot analyze TNoodle version here. We do not bother the user. - if (!runningVersion || !allowedTnoodleVersions) { + if (!runningVersion || !allowedTnoodleVersions || !currentTnoodle) { return null; } // Running version is not the most recent release - if (runningVersion !== currentTnoodle?.name) { + if (runningVersion !== currentTnoodle.name) { // Running version is allowed, but not the latest. - if (allowedTnoodleVersions.includes(runningVersion)) { + if (versionAllowed) { return (
You are running {runningVersion}, which is still allowed, - but you should upgrade to {currentTnoodle?.name} available{" "} - here as soon as + but you should upgrade to {currentTnoodle.name} available{" "} + here as soon as possible.
); @@ -90,7 +124,7 @@ const VersionInfo = () => { You are running {runningVersion}, which is not allowed. Do not use scrambles generated in any official competition and consider downloading the latest version{" "} - here. + here.
); } diff --git a/tnoodle-ui/src/main/model/Extension.ts b/tnoodle-ui/src/main/model/Extension.ts index 5083b35ae..a6130863b 100644 --- a/tnoodle-ui/src/main/model/Extension.ts +++ b/tnoodle-ui/src/main/model/Extension.ts @@ -1,4 +1,8 @@ -export default interface Extension { +export interface Extendable { + extensions: Extension[]; +} + +export interface Extension { id: string; specUrl: string; data: any; diff --git a/tnoodle-ui/src/main/model/FrontendStatus.ts b/tnoodle-ui/src/main/model/FrontendStatus.ts deleted file mode 100644 index 92eb3f99e..000000000 --- a/tnoodle-ui/src/main/model/FrontendStatus.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default interface FrontendStatus { - isStaging: boolean; - isManual: boolean; - isSignedBuild: boolean; - isAllowedVersion: boolean; -} diff --git a/tnoodle-ui/src/main/model/Round.ts b/tnoodle-ui/src/main/model/Round.ts index ad223345d..0ee32531a 100644 --- a/tnoodle-ui/src/main/model/Round.ts +++ b/tnoodle-ui/src/main/model/Round.ts @@ -1,9 +1,8 @@ -import Extension from "./Extension"; +import { Extendable } from "./Extension"; -export default interface Round { +export default interface Round extends Extendable { format: string; id: string; scrambleSetCount: string; - extensions: Extension[]; timeLimit?: number | null; } diff --git a/tnoodle-ui/src/main/model/ScrambleAndImage.ts b/tnoodle-ui/src/main/model/ScrambleAndImage.ts new file mode 100644 index 000000000..019e34416 --- /dev/null +++ b/tnoodle-ui/src/main/model/ScrambleAndImage.ts @@ -0,0 +1,4 @@ +export default interface ScrambleAndImage { + scramble: string; + svgImage: string; +} diff --git a/tnoodle-ui/src/main/model/Translation.ts b/tnoodle-ui/src/main/model/Translation.ts deleted file mode 100644 index 405d64b99..000000000 --- a/tnoodle-ui/src/main/model/Translation.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default interface Translation { - id: string; // "pt-BR" - name: string; // "Portuguese (Brazil)"; - status: boolean; -} diff --git a/tnoodle-ui/src/main/model/WcaEvent.ts b/tnoodle-ui/src/main/model/WcaEvent.ts index 3efb634f4..eb8cd38db 100644 --- a/tnoodle-ui/src/main/model/WcaEvent.ts +++ b/tnoodle-ui/src/main/model/WcaEvent.ts @@ -1,6 +1,8 @@ export default interface WcaEvent { id: string; name: string; + puzzle_id: string; + puzzle_group_id: string | null; format_ids: string[]; is_fewest_moves: boolean; is_multiple_blindfolded: boolean; diff --git a/tnoodle-ui/src/main/model/Wcif.ts b/tnoodle-ui/src/main/model/Wcif.ts index cb2f4b2e0..0b0dbf737 100644 --- a/tnoodle-ui/src/main/model/Wcif.ts +++ b/tnoodle-ui/src/main/model/Wcif.ts @@ -1,8 +1,9 @@ import Person from "./Person"; import Schedule from "./Schedule"; import WcifEvent from "./WcifEvent"; +import { Extendable } from "./Extension"; -export default interface Wcif { +export default interface Wcif extends Extendable { events: WcifEvent[]; formatVersion: string; id: string; diff --git a/tnoodle-ui/src/main/model/WcifEvent.ts b/tnoodle-ui/src/main/model/WcifEvent.ts index bf07acd41..7ee9973f3 100644 --- a/tnoodle-ui/src/main/model/WcifEvent.ts +++ b/tnoodle-ui/src/main/model/WcifEvent.ts @@ -1,6 +1,7 @@ import Round from "./Round"; +import { Extendable } from "./Extension"; -export default interface WcifEvent { +export default interface WcifEvent extends Extendable { id: string; rounds: Round[]; } diff --git a/tnoodle-ui/src/main/redux/Store.ts b/tnoodle-ui/src/main/redux/Store.ts index 16abce799..6e8b87742 100644 --- a/tnoodle-ui/src/main/redux/Store.ts +++ b/tnoodle-ui/src/main/redux/Store.ts @@ -1,19 +1,17 @@ import { configureStore } from "@reduxjs/toolkit"; import { competitionSlice } from "./slice/CompetitionSlice"; -import { fmcSlice } from "./slice/FmcSlice"; import { informationSlice } from "./slice/InformationSlice"; -import { mbldSlice } from "./slice/MbldSlice"; import { scramblingSlice } from "./slice/ScramblingSlice"; import { wcifSlice } from "./slice/WcifSlice"; +import { eventDataSlice } from "./slice/EventDataSlice"; const store = configureStore({ reducer: { competitionSlice: competitionSlice.reducer, - fmcSlice: fmcSlice.reducer, informationSlice: informationSlice.reducer, - mbldSlice: mbldSlice.reducer, scramblingSlice: scramblingSlice.reducer, wcifSlice: wcifSlice.reducer, + eventDataSlice: eventDataSlice.reducer, }, }); export default store; diff --git a/tnoodle-ui/src/main/redux/slice/EventDataSlice.ts b/tnoodle-ui/src/main/redux/slice/EventDataSlice.ts new file mode 100644 index 000000000..06beab42d --- /dev/null +++ b/tnoodle-ui/src/main/redux/slice/EventDataSlice.ts @@ -0,0 +1,33 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +interface EventDataState { + bestMbldAttempt?: number; + suggestedFmcTranslations?: string[]; +} + +const initialState: EventDataState = { + bestMbldAttempt: undefined, + suggestedFmcTranslations: undefined, +}; + +export const eventDataSlice = createSlice({ + name: "eventDataSlice", + initialState, + reducers: { + setBestMbldAttempt: ( + state, + action: PayloadAction + ) => { + state.bestMbldAttempt = action.payload; + }, + setSuggestedFmcTranslations: ( + state, + action: PayloadAction + ) => { + state.suggestedFmcTranslations = action.payload; + }, + }, +}); + +export const { setSuggestedFmcTranslations, setBestMbldAttempt } = + eventDataSlice.actions; diff --git a/tnoodle-ui/src/main/redux/slice/FmcSlice.ts b/tnoodle-ui/src/main/redux/slice/FmcSlice.ts deleted file mode 100644 index 2f5b43868..000000000 --- a/tnoodle-ui/src/main/redux/slice/FmcSlice.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import Translation from "../../model/Translation"; - -interface FmcState { - suggestedFmcTranslations?: string[]; - translations?: Translation[]; -} - -const initialState: FmcState = { - suggestedFmcTranslations: undefined, - translations: undefined, -}; - -export const fmcSlice = createSlice({ - name: "fmcSlice", - initialState, - reducers: { - updateTranslationStatus: ( - state, - action: PayloadAction<{ id: string; status: boolean }> - ) => { - state.translations = state.translations?.map((translation) => ({ - ...translation, - status: - translation.id === action.payload.id - ? action.payload.status - : translation.status, - })); - }, - updateAllTranslationsStatus: ( - state, - action: PayloadAction - ) => { - state.translations = state.translations?.map((translation) => ({ - ...translation, - status: action.payload, - })); - }, - filterSuggestedFmcTranslations: ( - state, - action: PayloadAction - ) => { - state.translations = state.translations!.map((translation) => ({ - ...translation, - status: !!action.payload?.includes(translation.id), - })); - }, - setSuggestedFmcTranslations: ( - state, - action: PayloadAction - ) => { - state.suggestedFmcTranslations = action.payload; - }, - setTranslations: ( - state, - action: PayloadAction - ) => { - state.translations = action.payload; - }, - }, -}); - -export const { - filterSuggestedFmcTranslations, - setSuggestedFmcTranslations, - setTranslations, - updateTranslationStatus, - updateAllTranslationsStatus, -} = fmcSlice.actions; diff --git a/tnoodle-ui/src/main/redux/slice/MbldSlice.ts b/tnoodle-ui/src/main/redux/slice/MbldSlice.ts deleted file mode 100644 index 6c26a6a9d..000000000 --- a/tnoodle-ui/src/main/redux/slice/MbldSlice.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { MBLD_DEFAULT } from "../../constants/wca.constants"; - -interface MbldState { - bestMbldAttempt?: number; - mbld: string; -} - -const initialState: MbldState = { - bestMbldAttempt: undefined, - mbld: "" + MBLD_DEFAULT, -}; - -export const mbldSlice = createSlice({ - name: "mbldSlice", - initialState, - reducers: { - setMbld: (state, action: PayloadAction) => { - state.mbld = action.payload; - }, - setBestMbldAttempt: ( - state, - action: PayloadAction - ) => { - state.bestMbldAttempt = action.payload; - }, - }, -}); - -export const { setMbld, setBestMbldAttempt } = mbldSlice.actions; diff --git a/tnoodle-ui/src/main/redux/slice/ScramblingSlice.ts b/tnoodle-ui/src/main/redux/slice/ScramblingSlice.ts index 9229628bc..aa0450218 100644 --- a/tnoodle-ui/src/main/redux/slice/ScramblingSlice.ts +++ b/tnoodle-ui/src/main/redux/slice/ScramblingSlice.ts @@ -4,8 +4,6 @@ import WebsocketBlobResult from "../../model/WebsocketBlobResult"; interface ScramblingState { fileZip?: WebsocketBlobResult; generatingScrambles: boolean; - isValidSignedBuild: boolean; - isAllowedVersion: boolean; password: string; scramblingProgressCurrent: Record; scramblingProgressTarget: Record; @@ -14,8 +12,6 @@ interface ScramblingState { const initialState: ScramblingState = { fileZip: undefined, generatingScrambles: false, - isValidSignedBuild: false, - isAllowedVersion: false, password: "", scramblingProgressCurrent: {}, scramblingProgressTarget: {}, @@ -34,12 +30,6 @@ export const scramblingSlice = createSlice({ setGeneratingScrambles: (state, action: PayloadAction) => { state.generatingScrambles = action.payload; }, - setValidSignedBuild: (state, action: PayloadAction) => { - state.isValidSignedBuild = action.payload; - }, - setAllowedVersion: (state, action: PayloadAction) => { - state.isAllowedVersion = action.payload; - }, setPassword: (state, action: PayloadAction) => { state.password = action.payload; }, @@ -64,8 +54,6 @@ export const scramblingSlice = createSlice({ export const { setFileZip, - setValidSignedBuild, - setAllowedVersion, setPassword, setGeneratingScrambles, resetScramblingProgressCurrent, diff --git a/tnoodle-ui/src/main/redux/slice/WcifSlice.ts b/tnoodle-ui/src/main/redux/slice/WcifSlice.ts index a202336e6..ed34a246e 100644 --- a/tnoodle-ui/src/main/redux/slice/WcifSlice.ts +++ b/tnoodle-ui/src/main/redux/slice/WcifSlice.ts @@ -6,6 +6,8 @@ import WcifEvent from "../../model/WcifEvent"; import { competitionName2Id } from "../../util/competition.name.util"; import { copiesExtensionId, + fmcTranslationsExtensionId, + mbldCubesExtensionId, defaultWcif, getDefaultCopiesExtension, } from "../../util/wcif.util"; @@ -41,7 +43,7 @@ export const wcifSlice = createSlice({ setEditingStatus: (state, action: PayloadAction) => { state.editingStatus = action.payload; }, - setWcaEvent: (state, action: PayloadAction) => { + setWcifEvent: (state, action: PayloadAction) => { state.wcif = { ...state.wcif, events: [ @@ -75,6 +77,13 @@ export const wcifSlice = createSlice({ getDefaultCopiesExtension(), ], })), + extensions: [ + ...event.extensions.filter( + (it) => + it.id !== fmcTranslationsExtensionId && + it.id !== mbldCubesExtensionId + ), + ], })), }; }, @@ -84,7 +93,7 @@ export const wcifSlice = createSlice({ export const { setCompetitionName, setEditingStatus, - setWcaEvent, + setWcifEvent, setWcaEvents, setWcaFormats, setWcif, diff --git a/tnoodle-ui/src/main/util/extension.util.ts b/tnoodle-ui/src/main/util/extension.util.ts new file mode 100644 index 000000000..b8934ae05 --- /dev/null +++ b/tnoodle-ui/src/main/util/extension.util.ts @@ -0,0 +1,73 @@ +import { Extendable, Extension } from "../model/Extension"; +import _ from "lodash"; + +export const findExtension = ( + extendable: T, + extensionId: string +) => extendable.extensions.find((ext) => ext.id === extensionId); + +export const findAndProcessExtension = ( + extendable: T, + extensionId: string, + processExtension: (extension: Extension) => void +) => { + let extension = findExtension(extendable, extensionId); + + if (extension !== undefined) { + processExtension(extension); + } +}; + +export const removeExtension = ( + extendable: T, + extensionId: string +) => { + return { + ...extendable, + extensions: [ + ...extendable.extensions.filter((it) => it.id !== extensionId), + ], + }; +}; + +export const upsertExtension = ( + extendable: T, + extension: Extension +) => { + let withoutExtension = removeExtension(extendable, extension.id); + + return { + ...withoutExtension, + extensions: [...withoutExtension.extensions, extension], + }; +}; + +export const setExtensionLazily = ( + extendable: T, + extensionId: string, + buildExtension: () => Extension | null, + handleOnChange: (newExtendable: T) => void +) => { + let oldExtension = findExtension(extendable, extensionId); + let newExtension = buildExtension(); + + // null means that we should delete the extension. + if (newExtension === null) { + // is there is an extension that we _can_ delete in the first place? + if (oldExtension !== undefined) { + let removedExtendable = removeExtension(extendable, extensionId); + handleOnChange(removedExtendable); + } + } else { + let extensionDataEqual = _.isEqual( + oldExtension?.data, + newExtension.data + ); + + // did the extension data update? + if (!extensionDataEqual) { + let newExtendable = upsertExtension(extendable, newExtension); + handleOnChange(newExtendable); + } + } +}; diff --git a/tnoodle-ui/src/main/util/wcif.util.ts b/tnoodle-ui/src/main/util/wcif.util.ts index 8147884e6..83baf73e6 100644 --- a/tnoodle-ui/src/main/util/wcif.util.ts +++ b/tnoodle-ui/src/main/util/wcif.util.ts @@ -1,9 +1,18 @@ import Wcif from "../model/Wcif"; import WcifEvent from "../model/WcifEvent"; -import { getDefaultCompetitionName } from "../util/competition.name.util"; +import { getDefaultCompetitionName } from "./competition.name.util"; export const copiesExtensionId = "org.worldcubeassociation.tnoodle.SheetCopyCount"; +export const colorSchemeExtensionId = + "org.worldcubeassociation.tnoodle.ColorScheme"; +export const frontendStatusExtensionId = + "org.worldcubeassociation.tnoodle.CompetitionStatus"; +export const fmcTranslationsExtensionId = + "org.worldcubeassociation.tnoodle.FmcLanguages"; +export const mbldCubesExtensionId = + "org.worldcubeassociation.tnoodle.MultiScrambleCount"; + /** * This is the default extension object the backend expects * @param {} copies @@ -29,6 +38,7 @@ let default333: WcifEvent = { extensions: [getDefaultCopiesExtension()], }, ], + extensions: [], }; let name = getDefaultCompetitionName(); @@ -40,4 +50,5 @@ export const defaultWcif: Wcif = { events: [default333], persons: [], schedule: { numberOfDays: 0, venues: [] }, + extensions: [], }; diff --git a/tnoodle-ui/src/test/App.test.tsx b/tnoodle-ui/src/test/App.test.tsx index 7c076e746..1dfe84af1 100644 --- a/tnoodle-ui/src/test/App.test.tsx +++ b/tnoodle-ui/src/test/App.test.tsx @@ -1,21 +1,22 @@ import { render, act, fireEvent } from "@testing-library/react"; -import { shuffle } from "lodash"; +import { shuffle, difference } from "lodash"; import React from "react"; import { Provider } from "react-redux"; import App from "../App"; import tnoodleApi from "../main/api/tnoodle.api"; import wcaApi from "../main/api/wca.api"; -import Translation from "../main/model/Translation"; -import FrontendStatus from "../main/model/FrontendStatus"; import Wcif from "../main/model/Wcif"; -import { defaultWcif } from "../main/util/wcif.util"; +import { defaultWcif, frontendStatusExtensionId } from "../main/util/wcif.util"; import { bestMbldAttempt, + colorScheme, defaultStatus, + emptySvg, events, formats, languages, plainZip, + scrambleAndImage, version, } from "./mock/tnoodle.api.test.mock"; import { axiosResponse, getNewStore } from "./mock/util.test.mock"; @@ -25,14 +26,16 @@ import { scrambleProgram, wcifs, } from "./mock/wca.api.test.mock"; +import { + getFmcLanguageTags, + getMbldCubesCount, +} from "./util/extension.test.util"; +import { findExtension } from "../main/util/extension.util"; let container = document.createElement("div"); let wcif: Wcif | null = null; -let mbld: string | null = null; let password: string | null = null; -let status: FrontendStatus | null = null; -let translations: Translation[] | undefined; beforeEach(() => { // setup a DOM element as a render target container = document.createElement("div"); @@ -54,17 +57,26 @@ beforeEach(() => { () => Promise.resolve({ data: languages, ...axiosResponse }) ); + jest.spyOn(tnoodleApi, "fetchPuzzleColorScheme").mockImplementation(() => + Promise.resolve({ data: colorScheme, ...axiosResponse }) + ); + + jest.spyOn(tnoodleApi, "fetchPuzzleRandomScramble").mockImplementation(() => + Promise.resolve({ data: scrambleAndImage, ...axiosResponse }) + ); + + jest.spyOn(tnoodleApi, "fetchPuzzleSolvedSvg").mockImplementation(() => + Promise.resolve({ data: emptySvg, ...axiosResponse }) + ); + jest.spyOn(tnoodleApi, "fetchRunningVersion").mockImplementation(() => Promise.resolve({ data: version, ...axiosResponse }) ); jest.spyOn(tnoodleApi, "fetchZip").mockImplementation( - (scrambleClient, _wcif, _mbld, _password, _status, _translations) => { + (scrambleClient, _wcif, _password) => { wcif = _wcif; - mbld = _mbld; password = _password; - status = _status; - translations = _translations; return Promise.resolve(plainZip); } @@ -93,14 +105,15 @@ afterEach(() => { container = document.createElement("div"); wcif = null; - mbld = null; password = null; - translations = undefined; // Clear mock jest.spyOn(tnoodleApi, "fetchWcaEvents").mockRestore(); jest.spyOn(tnoodleApi, "fetchFormats").mockRestore(); jest.spyOn(tnoodleApi, "fetchAvailableFmcTranslations").mockRestore(); + jest.spyOn(tnoodleApi, "fetchPuzzleColorScheme").mockRestore(); + jest.spyOn(tnoodleApi, "fetchPuzzleRandomScramble").mockRestore(); + jest.spyOn(tnoodleApi, "fetchPuzzleSolvedSvg").mockRestore(); jest.spyOn(tnoodleApi, "fetchRunningVersion").mockRestore(); jest.spyOn(tnoodleApi, "fetchZip").mockRestore(); jest.spyOn(wcaApi, "getUpcomingManageableCompetitions").mockRestore(); @@ -135,7 +148,12 @@ it("Just generate scrambles", async () => { expect(wcif!.events.length).toBe(1); expect(password).toBe(""); - expect(status).toEqual(defaultStatus); + + let wcifStatus = findExtension( + store.getState().wcifSlice.wcif, + frontendStatusExtensionId + ); + expect(wcifStatus!.data).toEqual(defaultStatus); }); it("Changes on 333, scramble", async () => { @@ -235,14 +253,14 @@ it("Remove 333, add FMC and MBLD", async () => { let mbldCubes = "70"; // Pick random indexes from fmc to deselect - let laguageKeys = Object.keys(languages); - let numberOfLanguages = laguageKeys.length; + let languageKeys = Object.keys(languages); + let numberOfLanguages = languageKeys.length; // At least 1, we do not deselect every translation let languagesToDeselect = Math.floor(Math.random() * numberOfLanguages - 2) + 1; - let languagesIndexToDelesect = shuffle([ + let languagesIndexToSelect = shuffle([ ...Array.from(Array(numberOfLanguages).keys()), ]).slice(languagesToDeselect); @@ -282,13 +300,11 @@ it("Remove 333, add FMC and MBLD", async () => { for ( let index = 0; - index < languagesIndexToDelesect.length; + index < languagesIndexToSelect.length; index++ ) { await act(async () => { - fireEvent.click( - checkboxes[languagesIndexToDelesect[index]] - ); + fireEvent.click(checkboxes[languagesIndexToSelect[index]]); }); } } @@ -302,25 +318,23 @@ it("Remove 333, add FMC and MBLD", async () => { expect(wcif!.events.length).toBe(events.length); - expect(mbld).toBe(mbldCubes); + let mbldExtensionCubesCount = getMbldCubesCount(store); + expect(mbldExtensionCubesCount).toBe(mbldCubes); - let selected = translations! - .filter((translation) => translation.status) - .map((translation) => translation.id); + let fmcExtensionLanguageTags = getFmcLanguageTags(store); - let deselected = translations! - .filter((translation) => !translation.status) - .map((translation) => translation.id) - .sort(); + let selected = [...fmcExtensionLanguageTags].sort(); // Deselected should be with status false - expect(deselected).toEqual( - languagesIndexToDelesect.map((index) => laguageKeys[index]).sort() + expect(selected).toEqual( + languagesIndexToSelect.map((index) => languageKeys[index]).sort() ); + let deselected = difference(languageKeys, selected).sort(); + // Selected and deselected should cover every languages expect([...selected, ...deselected].sort()).toStrictEqual( - laguageKeys.sort() + languageKeys.sort() ); }); @@ -392,13 +406,13 @@ it("Logged user", async () => { ); }); + let mbldExtensionCubesCount = getMbldCubesCount(store); + // We should warn in case of mbld if ( - !!store - .getState() - .wcifSlice.wcif.events.find((event) => event.id === "333mbf") && - store.getState().mbldSlice.bestMbldAttempt! > - Number(store.getState().mbldSlice.mbld) + !!mbldExtensionCubesCount && + store.getState().eventDataSlice.bestMbldAttempt! > + Number(mbldExtensionCubesCount) ) { let items = container.querySelectorAll("tfoot tr th[colspan]"); expect(items[items.length - 1].innerHTML).toContain( diff --git a/tnoodle-ui/src/test/EventPicker.test.tsx b/tnoodle-ui/src/test/EventPicker.test.tsx index 07c27dfc6..49a698ab0 100644 --- a/tnoodle-ui/src/test/EventPicker.test.tsx +++ b/tnoodle-ui/src/test/EventPicker.test.tsx @@ -10,19 +10,42 @@ import { import { setEditingStatus } from "../main/redux/slice/WcifSlice"; import store from "../main/redux/Store"; import { defaultWcif } from "../main/util/wcif.util"; -import { events } from "./mock/tnoodle.api.test.mock"; +import { + colorScheme, + emptySvg, + events, + scrambleAndImage, +} from "./mock/tnoodle.api.test.mock"; +import tnoodleApi from "../main/api/tnoodle.api"; +import { axiosResponse } from "./mock/util.test.mock"; let container = document.createElement("div"); beforeEach(() => { // setup a DOM element as a render target container = document.createElement("div"); document.body.appendChild(container); + + jest.spyOn(tnoodleApi, "fetchPuzzleColorScheme").mockImplementation(() => + Promise.resolve({ data: colorScheme, ...axiosResponse }) + ); + + jest.spyOn(tnoodleApi, "fetchPuzzleRandomScramble").mockImplementation(() => + Promise.resolve({ data: scrambleAndImage, ...axiosResponse }) + ); + + jest.spyOn(tnoodleApi, "fetchPuzzleSolvedSvg").mockImplementation(() => + Promise.resolve({ data: emptySvg, ...axiosResponse }) + ); }); afterEach(() => { // cleanup on exiting container.remove(); container = document.createElement("div"); + + jest.spyOn(tnoodleApi, "fetchPuzzleColorScheme").mockRestore(); + jest.spyOn(tnoodleApi, "fetchPuzzleRandomScramble").mockRestore(); + jest.spyOn(tnoodleApi, "fetchPuzzleSolvedSvg").mockRestore(); }); it("Changing values from event", async () => { diff --git a/tnoodle-ui/src/test/EventPickerTable.test.tsx b/tnoodle-ui/src/test/EventPickerTable.test.tsx index 6eaa7ff47..7d70ead65 100644 --- a/tnoodle-ui/src/test/EventPickerTable.test.tsx +++ b/tnoodle-ui/src/test/EventPickerTable.test.tsx @@ -4,9 +4,19 @@ import { Provider } from "react-redux"; import tnoodleApi from "../main/api/tnoodle.api"; import EventPickerTable from "../main/components/EventPickerTable"; import { setCompetitionId } from "../main/redux/slice/CompetitionSlice"; -import { setEditingStatus, setWcaEvent } from "../main/redux/slice/WcifSlice"; -import { getDefaultCopiesExtension } from "../main/util/wcif.util"; -import { events, formats, languages } from "./mock/tnoodle.api.test.mock"; +import { setEditingStatus, setWcifEvent } from "../main/redux/slice/WcifSlice"; +import { + getDefaultCopiesExtension, + mbldCubesExtensionId, +} from "../main/util/wcif.util"; +import { + colorScheme, + emptySvg, + events, + formats, + languages, + scrambleAndImage, +} from "./mock/tnoodle.api.test.mock"; import { axiosResponse, getNewStore } from "./mock/util.test.mock"; import { competitions } from "./mock/wca.api.test.mock"; @@ -30,6 +40,18 @@ beforeEach(() => { jest.spyOn(tnoodleApi, "fetchAvailableFmcTranslations").mockImplementation( () => Promise.resolve({ data: languages, ...axiosResponse }) ); + + jest.spyOn(tnoodleApi, "fetchPuzzleColorScheme").mockImplementation(() => + Promise.resolve({ data: colorScheme, ...axiosResponse }) + ); + + jest.spyOn(tnoodleApi, "fetchPuzzleRandomScramble").mockImplementation(() => + Promise.resolve({ data: scrambleAndImage, ...axiosResponse }) + ); + + jest.spyOn(tnoodleApi, "fetchPuzzleSolvedSvg").mockImplementation(() => + Promise.resolve({ data: emptySvg, ...axiosResponse }) + ); }); afterEach(() => { @@ -41,6 +63,9 @@ afterEach(() => { jest.spyOn(tnoodleApi, "fetchWcaEvents").mockRestore(); jest.spyOn(tnoodleApi, "fetchFormats").mockRestore(); jest.spyOn(tnoodleApi, "fetchAvailableFmcTranslations").mockRestore(); + jest.spyOn(tnoodleApi, "fetchPuzzleColorScheme").mockRestore(); + jest.spyOn(tnoodleApi, "fetchPuzzleRandomScramble").mockRestore(); + jest.spyOn(tnoodleApi, "fetchPuzzleSolvedSvg").mockRestore(); }); it("Show editing warn if case of competition selected", async () => { @@ -64,8 +89,9 @@ it("Show editing warn if case of competition selected", async () => { extensions: [getDefaultCopiesExtension()], }, ], + extensions: [], }; - store.dispatch(setWcaEvent(newEvent)); + store.dispatch(setWcifEvent(newEvent)); // Render component await act(async () => { @@ -159,6 +185,13 @@ it("Changes in MBLD should go to the store", async () => { }); }); + let mbldWcifEvent = store + .getState() + .wcifSlice.wcif.events.find((event) => event.id === "333mbf"); + let mbldExtensionCubesCount = mbldWcifEvent?.extensions?.find( + (extension) => extension.id === mbldCubesExtensionId + )?.data["requestedScrambles"]; + // It should go to the store - expect(store.getState().mbldSlice.mbld).toBe(newMbldScrambles); + expect(mbldExtensionCubesCount).toBe(newMbldScrambles); }); diff --git a/tnoodle-ui/src/test/Main.test.tsx b/tnoodle-ui/src/test/Main.test.tsx index e8b666ad8..6f220d370 100644 --- a/tnoodle-ui/src/test/Main.test.tsx +++ b/tnoodle-ui/src/test/Main.test.tsx @@ -4,18 +4,19 @@ import { Provider } from "react-redux"; import tnoodleApi from "../main/api/tnoodle.api"; import wcaApi from "../main/api/wca.api"; import Main from "../main/components/Main"; +import { setSuggestedFmcTranslations } from "../main/redux/slice/EventDataSlice"; import { - setSuggestedFmcTranslations, - setTranslations, -} from "../main/redux/slice/FmcSlice"; -import { + colorScheme, + emptySvg, events, formats, languages, + scrambleAndImage, version, } from "./mock/tnoodle.api.test.mock"; import { axiosResponse, getNewStore } from "./mock/util.test.mock"; import { scrambleProgram } from "./mock/wca.api.test.mock"; +import { getFmcLanguageTags } from "./util/extension.test.util"; let container = document.createElement("div"); beforeEach(() => { @@ -39,6 +40,9 @@ afterEach(() => { jest.spyOn(tnoodleApi, "fetchRunningVersion").mockRestore(); jest.spyOn(wcaApi, "fetchVersionInfo").mockRestore(); + jest.spyOn(tnoodleApi, "fetchPuzzleColorScheme").mockRestore(); + jest.spyOn(tnoodleApi, "fetchPuzzleRandomScramble").mockRestore(); + jest.spyOn(tnoodleApi, "fetchPuzzleSolvedSvg").mockRestore(); }); it("There should be only 1 button of type submit, check FMC changes", async () => { @@ -60,15 +64,20 @@ it("There should be only 1 button of type submit, check FMC changes", async () = () => Promise.resolve({ data: languages, ...axiosResponse }) ); + jest.spyOn(tnoodleApi, "fetchPuzzleColorScheme").mockImplementation(() => + Promise.resolve({ data: colorScheme, ...axiosResponse }) + ); + + jest.spyOn(tnoodleApi, "fetchPuzzleRandomScramble").mockImplementation(() => + Promise.resolve({ data: scrambleAndImage, ...axiosResponse }) + ); + + jest.spyOn(tnoodleApi, "fetchPuzzleSolvedSvg").mockImplementation(() => + Promise.resolve({ data: emptySvg, ...axiosResponse }) + ); + // We add suggested FMC so the button Select Suggested appears as well - const translations = Object.entries(languages).map(([id, name]) => ({ - id, - name, - status: true, - })); const suggestedFmcTranslations = ["de", "en", "pt-BR"]; - - store.dispatch(setTranslations(translations)); store.dispatch(setSuggestedFmcTranslations(suggestedFmcTranslations)); // Render component @@ -127,56 +136,47 @@ it("There should be only 1 button of type submit, check FMC changes", async () = // The only submit button must be Generate Scrambles expect(buttonsTypeSubmit[0].innerHTML).toBe("Generate Scrambles"); - // At first, all translations should be selected - store.getState().fmcSlice.translations!.forEach((translation) => { - expect(translation.status).toEqual(true); - }); + // At first, there should be no translation information at all + expect(getFmcLanguageTags(store)).toBeUndefined(); // Select suggested await act(async () => { fireEvent.click(completeButtons[completeButtons.length - 1]); }); - store.getState().fmcSlice.translations!.forEach((translation) => { - expect(translation.status).toEqual( - suggestedFmcTranslations.indexOf(translation.id) >= 0 - ); - }); + expect(getFmcLanguageTags(store)).toEqual(suggestedFmcTranslations); // Select None await act(async () => { fireEvent.click(completeButtons[completeButtons.length - 2]); }); - store.getState().fmcSlice.translations!.forEach((translation) => { - expect(translation.status).toEqual(false); - }); + + expect(getFmcLanguageTags(store)).toBeUndefined(); // Select All await act(async () => { fireEvent.click(completeButtons[completeButtons.length - 3]); }); - store.getState().fmcSlice.translations!.forEach((translation) => { - expect(translation.status).toEqual(true); - }); + + expect(getFmcLanguageTags(store)).toEqual(Object.keys(languages)); // Here, we test just a single random language toggle let index = Math.floor(Math.random() * Object.keys(languages).length); + let language = Object.keys(languages)[index]; const checkbox = Array.from( container.querySelectorAll("input[type=checkbox]") )[index] as HTMLInputElement; - expect(checkbox.id).toBe( - "fmc-" + store.getState().fmcSlice.translations![index].id - ); + expect(checkbox.id).toBe("fmc-" + language); // Check toggle behavior and its value in the store expect(checkbox.checked).toBe(true); - expect(store.getState().fmcSlice.translations![index].status).toBe(true); + expect(getFmcLanguageTags(store)).toContain(language); fireEvent.click(checkbox); expect(checkbox.checked).toBe(false); - expect(store.getState().fmcSlice.translations![index].status).toBe(false); + expect(getFmcLanguageTags(store)).not.toContain(language); fireEvent.click(checkbox); expect(checkbox.checked).toBe(true); - expect(store.getState().fmcSlice.translations![index].status).toBe(true); + expect(getFmcLanguageTags(store)).toContain(language); // Clear mock fetchWcaEvents // Clear mock diff --git a/tnoodle-ui/src/test/mock/tnoodle.api.test.mock.ts b/tnoodle-ui/src/test/mock/tnoodle.api.test.mock.ts index eee69ce83..78aad1287 100644 --- a/tnoodle-ui/src/test/mock/tnoodle.api.test.mock.ts +++ b/tnoodle-ui/src/test/mock/tnoodle.api.test.mock.ts @@ -2,6 +2,8 @@ export const events = [ { id: "333", name: "3x3x3", + puzzle_id: "333", + puzzle_group_id: "nbyn", format_ids: ["a", "3", "2", "1"], can_change_time_limit: true, is_timed_event: true, @@ -11,6 +13,8 @@ export const events = [ { id: "222", name: "2x2x2", + puzzle_id: "222", + puzzle_group_id: "nbyn", format_ids: ["a", "3", "2", "1"], can_change_time_limit: true, is_timed_event: true, @@ -20,6 +24,8 @@ export const events = [ { id: "444", name: "4x4x4", + puzzle_id: "444", + puzzle_group_id: "nbyn", format_ids: ["a", "3", "2", "1"], can_change_time_limit: true, is_timed_event: true, @@ -29,6 +35,8 @@ export const events = [ { id: "555", name: "5x5x5", + puzzle_id: "555", + puzzle_group_id: "nbyn", format_ids: ["a", "3", "2", "1"], can_change_time_limit: true, is_timed_event: true, @@ -38,6 +46,8 @@ export const events = [ { id: "666", name: "6x6x6", + puzzle_id: "666", + puzzle_group_id: "nbyn", format_ids: ["m", "2", "1"], can_change_time_limit: true, is_timed_event: true, @@ -47,6 +57,8 @@ export const events = [ { id: "777", name: "7x7x7", + puzzle_id: "777", + puzzle_group_id: "nbyn", format_ids: ["m", "2", "1"], can_change_time_limit: true, is_timed_event: true, @@ -56,6 +68,8 @@ export const events = [ { id: "333bf", name: "3x3x3 Blindfolded", + puzzle_id: "333", + puzzle_group_id: "nbyn", format_ids: ["3", "2", "1"], can_change_time_limit: true, is_timed_event: true, @@ -65,6 +79,8 @@ export const events = [ { id: "333fm", name: "3x3x3 Fewest Moves", + puzzle_id: "333", + puzzle_group_id: "nbyn", format_ids: ["m", "2", "1"], can_change_time_limit: false, is_timed_event: false, @@ -74,6 +90,8 @@ export const events = [ { id: "333oh", name: "3x3x3 One-Handed", + puzzle_id: "333", + puzzle_group_id: "nbyn", format_ids: ["a", "3", "2", "1"], can_change_time_limit: true, is_timed_event: true, @@ -83,6 +101,8 @@ export const events = [ { id: "clock", name: "Clock", + puzzle_id: "clock", + puzzle_group_id: null, format_ids: ["a", "3", "2", "1"], can_change_time_limit: true, is_timed_event: true, @@ -92,6 +112,8 @@ export const events = [ { id: "minx", name: "Megaminx", + puzzle_id: "minx", + puzzle_group_id: null, format_ids: ["a", "3", "2", "1"], can_change_time_limit: true, is_timed_event: true, @@ -101,6 +123,8 @@ export const events = [ { id: "pyram", name: "Pyraminx", + puzzle_id: "pyram", + puzzle_group_id: null, format_ids: ["a", "3", "2", "1"], can_change_time_limit: true, is_timed_event: true, @@ -110,6 +134,8 @@ export const events = [ { id: "skewb", name: "Skewb", + puzzle_id: "skewb", + puzzle_group_id: null, format_ids: ["a", "3", "2", "1"], can_change_time_limit: true, is_timed_event: true, @@ -119,6 +145,8 @@ export const events = [ { id: "sq1", name: "Square-1", + puzzle_id: "sq1", + puzzle_group_id: null, format_ids: ["a", "3", "2", "1"], can_change_time_limit: true, is_timed_event: true, @@ -128,6 +156,8 @@ export const events = [ { id: "444bf", name: "4x4x4 Blindfolded", + puzzle_id: "444", + puzzle_group_id: "nbyn", format_ids: ["3", "2", "1"], can_change_time_limit: true, is_timed_event: true, @@ -137,6 +167,8 @@ export const events = [ { id: "555bf", name: "5x5x5 Blindfolded", + puzzle_id: "555", + puzzle_group_id: null, format_ids: ["3", "2", "1"], can_change_time_limit: true, is_timed_event: true, @@ -146,6 +178,8 @@ export const events = [ { id: "333mbf", name: "3x3x3 Multiple Blindfolded", + puzzle_id: "333", + puzzle_group_id: null, format_ids: ["3", "2", "1"], can_change_time_limit: false, is_timed_event: false, @@ -211,3 +245,19 @@ export const bestMbldAttempt = { attempted: 60, time: 3012, }; + +export const emptySvg = ""; + +export const colorScheme = { + U: "#FFFFFF", + F: "#00FF00", + R: "#FF0000", + D: "#FFFF00", + B: "#0000FF", + L: "#FF8000", +}; + +export const scrambleAndImage = { + scramble: "R U R' U' R' F R2 U' R' U' R U R' F'", + svgImage: emptySvg, +}; diff --git a/tnoodle-ui/src/test/mock/util.test.mock.ts b/tnoodle-ui/src/test/mock/util.test.mock.ts index 6d89e1283..c7c478caa 100644 --- a/tnoodle-ui/src/test/mock/util.test.mock.ts +++ b/tnoodle-ui/src/test/mock/util.test.mock.ts @@ -1,11 +1,10 @@ import { configureStore } from "@reduxjs/toolkit"; import { competitionSlice } from "../../main/redux/slice/CompetitionSlice"; -import { fmcSlice } from "../../main/redux/slice/FmcSlice"; import { informationSlice } from "../../main/redux/slice/InformationSlice"; -import { mbldSlice } from "../../main/redux/slice/MbldSlice"; import { scramblingSlice } from "../../main/redux/slice/ScramblingSlice"; import { wcifSlice } from "../../main/redux/slice/WcifSlice"; import { AxiosHeaders } from "axios"; +import { eventDataSlice } from "../../main/redux/slice/EventDataSlice"; export const axiosResponse = { status: 200, @@ -20,10 +19,9 @@ export const getNewStore = () => configureStore({ reducer: { competitionSlice: competitionSlice.reducer, - fmcSlice: fmcSlice.reducer, informationSlice: informationSlice.reducer, - mbldSlice: mbldSlice.reducer, scramblingSlice: scramblingSlice.reducer, wcifSlice: wcifSlice.reducer, + eventDataSlice: eventDataSlice.reducer, }, }); diff --git a/tnoodle-ui/src/test/mock/wca.api.test.mock.ts b/tnoodle-ui/src/test/mock/wca.api.test.mock.ts index 13bffa13a..96d933d8e 100644 --- a/tnoodle-ui/src/test/mock/wca.api.test.mock.ts +++ b/tnoodle-ui/src/test/mock/wca.api.test.mock.ts @@ -100,6 +100,7 @@ export const wcifs: Record = { ], }, ], + extensions: [], }, { id: "222", @@ -147,6 +148,7 @@ export const wcifs: Record = { scrambleSetCount: "1", }, ], + extensions: [], }, { id: "444", @@ -194,6 +196,7 @@ export const wcifs: Record = { scrambleSetCount: "1", }, ], + extensions: [], }, { id: "555", @@ -241,6 +244,7 @@ export const wcifs: Record = { scrambleSetCount: "1", }, ], + extensions: [], }, { id: "666", @@ -274,6 +278,7 @@ export const wcifs: Record = { scrambleSetCount: "1", }, ], + extensions: [], }, { id: "777", @@ -307,6 +312,7 @@ export const wcifs: Record = { scrambleSetCount: "1", }, ], + extensions: [], }, { id: "333bf", @@ -354,6 +360,7 @@ export const wcifs: Record = { scrambleSetCount: "1", }, ], + extensions: [], }, { id: "333fm", @@ -374,6 +381,7 @@ export const wcifs: Record = { scrambleSetCount: "1", }, ], + extensions: [], }, { id: "333oh", @@ -421,6 +429,7 @@ export const wcifs: Record = { scrambleSetCount: "1", }, ], + extensions: [], }, { id: "clock", @@ -454,6 +463,7 @@ export const wcifs: Record = { scrambleSetCount: "1", }, ], + extensions: [], }, { id: "minx", @@ -487,6 +497,7 @@ export const wcifs: Record = { scrambleSetCount: "1", }, ], + extensions: [], }, { id: "pyram", @@ -534,6 +545,7 @@ export const wcifs: Record = { scrambleSetCount: "1", }, ], + extensions: [], }, { id: "skewb", @@ -581,6 +593,7 @@ export const wcifs: Record = { scrambleSetCount: "1", }, ], + extensions: [], }, { id: "sq1", @@ -628,6 +641,7 @@ export const wcifs: Record = { scrambleSetCount: "1", }, ], + extensions: [], }, { id: "444bf", @@ -647,6 +661,7 @@ export const wcifs: Record = { scrambleSetCount: "3", }, ], + extensions: [], }, { id: "555bf", @@ -666,6 +681,7 @@ export const wcifs: Record = { scrambleSetCount: "2", }, ], + extensions: [], }, { id: "333mbf", @@ -686,6 +702,7 @@ export const wcifs: Record = { scrambleSetCount: "1", }, ], + extensions: [], }, { id: "333ft", @@ -719,10 +736,23 @@ export const wcifs: Record = { scrambleSetCount: "1", }, ], + extensions: [], }, ], schedule: { numberOfDays: 0, venues: [] }, persons: [], + extensions: [ + { + data: { + isAllowedVersion: true, + isManual: false, + isSignedBuild: true, + isStaging: false, + }, + id: "org.worldcubeassociation.tnoodle.CompetitionStatus", + specUrl: "", + }, + ], }, [competitions[1].id]: { @@ -791,6 +821,7 @@ export const wcifs: Record = { scrambleSetCount: "1", }, ], + extensions: [], }, { id: "222", @@ -838,6 +869,7 @@ export const wcifs: Record = { scrambleSetCount: "1", }, ], + extensions: [], }, { id: "444", @@ -885,9 +917,22 @@ export const wcifs: Record = { scrambleSetCount: "1", }, ], + extensions: [], }, ], schedule: { numberOfDays: 0, venues: [] }, persons: [], + extensions: [ + { + data: { + isAllowedVersion: true, + isManual: false, + isSignedBuild: true, + isStaging: false, + }, + id: "org.worldcubeassociation.tnoodle.CompetitionStatus", + specUrl: "", + }, + ], }, }; diff --git a/tnoodle-ui/src/test/util/extension.test.util.ts b/tnoodle-ui/src/test/util/extension.test.util.ts new file mode 100644 index 000000000..5c27cdc6a --- /dev/null +++ b/tnoodle-ui/src/test/util/extension.test.util.ts @@ -0,0 +1,30 @@ +import store from "../../main/redux/Store"; +import { findExtension } from "../../main/util/extension.util"; +import { + fmcTranslationsExtensionId, + mbldCubesExtensionId, +} from "../../main/util/wcif.util"; + +export const getExtensionFromStore = ( + testStore: typeof store, + eventId: string, + extensionId: string +) => { + let wcifEvent = testStore + .getState() + .wcifSlice.wcif.events.find((event) => event.id === eventId); + + if (wcifEvent === undefined) { + return; + } + + return findExtension(wcifEvent, extensionId); +}; + +export const getMbldCubesCount = (testStore: typeof store) => + getExtensionFromStore(testStore, "333mbf", mbldCubesExtensionId)?.data + ?.requestedScrambles; + +export const getFmcLanguageTags = (testStore: typeof store) => + getExtensionFromStore(testStore, "333fm", fmcTranslationsExtensionId)?.data + ?.languageTags; diff --git a/tnoodle-ui/yarn.lock b/tnoodle-ui/yarn.lock index eced0928a..c5674681f 100644 --- a/tnoodle-ui/yarn.lock +++ b/tnoodle-ui/yarn.lock @@ -2039,6 +2039,11 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== +"@icons/material@^0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8" + integrity sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -2876,6 +2881,14 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== +"@types/react-color@^3.0.6": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@types/react-color/-/react-color-3.0.10.tgz#f869ab3a46938fb97a5c2ee568bc6469f82b14dd" + integrity sha512-6K5BAn3zyd8lW8UbckIAVeXGxR82Za9jyGD2DBEynsa7fKaguLDVtjfypzs7fgEV7bULgs7uhds8A8v1wABTvQ== + dependencies: + "@types/react" "*" + "@types/reactcss" "*" + "@types/react-dom@^18.0.0", "@types/react-dom@^18.2.17": version "18.2.17" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.17.tgz#375c55fab4ae671bd98448dcfa153268d01d6f64" @@ -2899,6 +2912,13 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/reactcss@*": + version "1.2.11" + resolved "https://registry.yarnpkg.com/@types/reactcss/-/reactcss-1.2.11.tgz#bfeadb67a704b4dba24c6c58d3c0e5cf87a0cb1b" + integrity sha512-0fFy0ubuPlhksId8r9V8nsLcxBAPQnn15g/ERAElgE9L6rOquMj2CapsxqfyBuHlkp0/ndEUVnkYI7MkTtkGpw== + dependencies: + "@types/react" "*" + "@types/resolve@1.17.1": version "1.17.1" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6" @@ -5224,6 +5244,11 @@ execa@^5.0.0: signal-exit "^3.0.3" strip-final-newline "^2.0.0" +exenv@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d" + integrity sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw== + exit@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" @@ -7053,6 +7078,11 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash-es@^4.17.15: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" @@ -7078,7 +7108,7 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: +lodash@^4.0.1, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -7130,6 +7160,11 @@ makeerror@1.0.x: dependencies: tmpl "1.0.x" +material-colors@^1.2.1: + version "1.2.6" + resolved "https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46" + integrity sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg== + mdn-data@2.0.14: version "2.0.14" resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" @@ -8375,7 +8410,7 @@ prop-types-extra@^1.1.0: react-is "^16.3.2" warning "^4.0.0" -prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.5.10, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -8493,6 +8528,19 @@ react-bootstrap@^2.9.1: uncontrollable "^7.2.1" warning "^4.0.3" +react-color@^2.19.3: + version "2.19.3" + resolved "https://registry.yarnpkg.com/react-color/-/react-color-2.19.3.tgz#ec6c6b4568312a3c6a18420ab0472e146aa5683d" + integrity sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA== + dependencies: + "@icons/material" "^0.2.4" + lodash "^4.17.15" + lodash-es "^4.17.15" + material-colors "^1.2.1" + prop-types "^15.5.10" + reactcss "^1.2.0" + tinycolor2 "^1.4.1" + react-dev-utils@^12.0.1: version "12.0.1" resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-12.0.1.tgz#ba92edb4a1f379bd46ccd6bcd4e7bc398df33e73" @@ -8536,11 +8584,24 @@ react-error-overlay@^6.0.11: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb" integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg== +react-from-dom@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/react-from-dom/-/react-from-dom-0.6.2.tgz#9da903a508c91c013b55afcd59348b8b0a39bdb4" + integrity sha512-qvWWTL/4xw4k/Dywd41RBpLQUSq97csuv15qrxN+izNeLYlD9wn5W8LspbfYe5CWbaSdkZ72BsaYBPQf2x4VbQ== + react-icons@^4.12.0: version "4.12.0" resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.12.0.tgz#54806159a966961bfd5cdb26e492f4dafd6a8d78" integrity sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw== +react-inlinesvg@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/react-inlinesvg/-/react-inlinesvg-3.0.3.tgz#b52d34869af2f7dd06f459302f5719ff70f0cb44" + integrity sha512-D9wqEyh1+ni07+CP2yaD9nSK11Y2ngd79xudEilX7YHKmUCeP1lXZqFvuLbdOo+m+oEjekd+c0DBc/bj93Lwqg== + dependencies: + exenv "^1.2.2" + react-from-dom "^0.6.2" + react-is@^16.13.1, react-is@^16.3.2: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -8646,6 +8707,13 @@ react@^18.2.0: dependencies: loose-envify "^1.1.0" +reactcss@^1.2.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/reactcss/-/reactcss-1.2.3.tgz#c00013875e557b1cf0dfd9a368a1c3dab3b548dd" + integrity sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A== + dependencies: + lodash "^4.0.1" + readable-stream@^2.0.1: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" @@ -9630,6 +9698,11 @@ timsort@^0.3.0: resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= +tinycolor2@^1.4.1: + version "1.6.0" + resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" + integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw== + tmpl@1.0.x: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/WebscramblesServer.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/WebscramblesServer.kt index 6630edd5d..8a7c6b3f6 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/WebscramblesServer.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/WebscramblesServer.kt @@ -25,6 +25,7 @@ import org.worldcubeassociation.tnoodle.server.webscrambles.exceptions.ScheduleM import org.worldcubeassociation.tnoodle.server.webscrambles.exceptions.ScrambleMatchingException import org.worldcubeassociation.tnoodle.server.webscrambles.exceptions.TranslationException import org.worldcubeassociation.tnoodle.server.webscrambles.routing.frontend.ApplicationDataHandler +import org.worldcubeassociation.tnoodle.server.webscrambles.routing.frontend.PuzzleDrawingHandler import org.worldcubeassociation.tnoodle.server.webscrambles.routing.frontend.WcifDataHandler import org.worldcubeassociation.tnoodle.server.webscrambles.serial.FrontendErrorMessage.Companion.frontendException import org.worldcubeassociation.tnoodle.server.webscrambles.server.MainLauncher @@ -44,6 +45,7 @@ class WebscramblesServer(val environmentConfig: ServerEnvironmentConfig) : Appli route("frontend") { ApplicationDataHandler.install(this) WcifDataHandler.install(this) + PuzzleDrawingHandler.install(this) } route("jobs") { diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/FewestMovesSheet.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/FewestMovesSheet.kt index 38af426bd..05892222e 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/FewestMovesSheet.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/FewestMovesSheet.kt @@ -2,6 +2,7 @@ package org.worldcubeassociation.tnoodle.server.webscrambles.pdf import org.worldcubeassociation.tnoodle.server.webscrambles.Translate import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model.* +import org.worldcubeassociation.tnoodle.svglite.Color import java.util.Locale abstract class FewestMovesSheet( @@ -12,8 +13,9 @@ abstract class FewestMovesSheet( activityCode: ActivityCode, hasGroupId: Boolean, locale: Locale, - watermark: String? -) : ScrambleSheet(competitionTitle, activityCode, hasGroupId, locale, watermark) { + watermark: String?, + colorScheme: Map?, +) : ScrambleSheet(competitionTitle, activityCode, hasGroupId, locale, watermark, colorScheme) { override val scrambles: List get() = listOfNotNull(scramble) diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/FmcCutoutSheet.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/FmcCutoutSheet.kt index 9651febdd..ea0a42622 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/FmcCutoutSheet.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/FmcCutoutSheet.kt @@ -9,6 +9,7 @@ import org.worldcubeassociation.tnoodle.server.webscrambles.pdf.model.properties import org.worldcubeassociation.tnoodle.server.webscrambles.pdf.model.properties.Paper.inchesToPixel import org.worldcubeassociation.tnoodle.server.webscrambles.pdf.model.properties.Paper.pixelsToInch import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model.* +import org.worldcubeassociation.tnoodle.svglite.Color import java.util.* class FmcCutoutSheet( @@ -19,8 +20,9 @@ class FmcCutoutSheet( activityCode: ActivityCode, hasGroupId: Boolean, locale: Locale, - watermark: String? = null -) : FewestMovesSheet(scramble, totalAttemptsNum, scrambleSetId, competitionTitle, activityCode, hasGroupId, locale, watermark) { + watermark: String? = null, + colorScheme: Map? = null, +) : FewestMovesSheet(scramble, totalAttemptsNum, scrambleSetId, competitionTitle, activityCode, hasGroupId, locale, watermark, colorScheme) { override fun DocumentBuilder.writeContents() { page { setVerticalMargins(MARGIN_VERTICAL) diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/FmcSolutionSheet.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/FmcSolutionSheet.kt index 355c6675c..842fb59ed 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/FmcSolutionSheet.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/FmcSolutionSheet.kt @@ -13,6 +13,7 @@ import org.worldcubeassociation.tnoodle.server.webscrambles.pdf.model.properties import org.worldcubeassociation.tnoodle.server.webscrambles.pdf.util.FontUtil import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.WCIFScrambleMatcher import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model.* +import org.worldcubeassociation.tnoodle.svglite.Color import java.util.* class FmcSolutionSheet( @@ -23,8 +24,9 @@ class FmcSolutionSheet( activityCode: ActivityCode, hasGroupId: Boolean, locale: Locale, - watermark: String? = null -) : FewestMovesSheet(scramble, totalAttemptsNum, scrambleSetId, competitionTitle, activityCode, hasGroupId, locale, watermark) { + watermark: String? = null, + colorScheme: Map? = null, +) : FewestMovesSheet(scramble, totalAttemptsNum, scrambleSetId, competitionTitle, activityCode, hasGroupId, locale, watermark, colorScheme) { private val drawScramble: Boolean get() = scramble.scrambleString.isNotEmpty() diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/GeneralScrambleSheet.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/GeneralScrambleSheet.kt index 335d15c60..6fe00a1ff 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/GeneralScrambleSheet.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/GeneralScrambleSheet.kt @@ -9,6 +9,7 @@ import org.worldcubeassociation.tnoodle.server.webscrambles.pdf.util.FontUtil import org.worldcubeassociation.tnoodle.server.webscrambles.pdf.util.ScramblePhrase import org.worldcubeassociation.tnoodle.server.webscrambles.pdf.util.ScrambleRow import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model.* +import org.worldcubeassociation.tnoodle.svglite.Color import java.util.* import kotlin.math.ceil import kotlin.math.max @@ -20,8 +21,9 @@ class GeneralScrambleSheet( activityCode: ActivityCode, hasGroupId: Boolean, locale: Locale, - watermark: String? = null -) : ScrambleSheet(competitionTitle, activityCode, hasGroupId, locale, watermark) { + watermark: String? = null, + colorScheme: Map? = null, +) : ScrambleSheet(competitionTitle, activityCode, hasGroupId, locale, watermark, colorScheme) { override val scrambles: List get() = scrambleSet.allScrambles diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/ScrambleSheet.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/ScrambleSheet.kt index e8cb0758e..a30821d21 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/ScrambleSheet.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/pdf/ScrambleSheet.kt @@ -7,14 +7,17 @@ import org.worldcubeassociation.tnoodle.server.webscrambles.pdf.model.dsl.Docume import org.worldcubeassociation.tnoodle.server.webscrambles.pdf.model.dsl.document import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model.ActivityCode import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model.Scramble +import org.worldcubeassociation.tnoodle.svglite.Color import java.util.* +import kotlin.collections.HashMap abstract class ScrambleSheet( val competitionTitle: String, val activityCode: ActivityCode, val hasGroupId: Boolean, val locale: Locale, - val watermark: String? = null + val watermark: String? = null, + val colorScheme: Map? = null, ) { abstract val scrambles: List abstract val scrambleSetId: Int // the scramble set this sheet refers to @@ -44,7 +47,7 @@ abstract class ScrambleSheet( ?: error("Cannot draw PDF: Scrambler model for $activityCode not found") protected fun CellBuilder.svgScrambleImage(scramble: String, maxWidthPx: Int, maxHeightPx: Int = 0): SvgImage { - val image = scramblingPuzzle.drawScramble(scramble, null) + val image = scramblingPuzzle.drawScramble(scramble, colorScheme?.let(::HashMap)) val fittingSize = scramblingPuzzle.getPreferredSize(maxWidthPx, maxHeightPx) return svgImage(image) { diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/routing/WcifHandler.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/routing/WcifHandler.kt index 287a3836b..308e893fd 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/routing/WcifHandler.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/routing/WcifHandler.kt @@ -8,6 +8,7 @@ import io.ktor.websocket.* import org.worldcubeassociation.tnoodle.server.RouteHandler import org.worldcubeassociation.tnoodle.server.serial.JsonConfig import org.worldcubeassociation.tnoodle.server.ServerEnvironmentConfig +import org.worldcubeassociation.tnoodle.server.model.EventData import org.worldcubeassociation.tnoodle.server.webscrambles.Translate import org.worldcubeassociation.tnoodle.server.webscrambles.exceptions.BadWcifParameterException import org.worldcubeassociation.tnoodle.server.webscrambles.routing.job.JobSchedulingHandler.registerJobPaths @@ -17,6 +18,7 @@ import org.worldcubeassociation.tnoodle.server.webscrambles.serial.WcifScrambleR import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.WCIFDataBuilder import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.WCIFScrambleMatcher import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model.Competition +import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model.extension.MultiScrambleCountExtension import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model.extension.SheetCopyCountExtension import java.time.LocalDateTime @@ -49,15 +51,15 @@ class WcifHandler(val environmentConfig: ServerEnvironmentConfig) : RouteHandler } override suspend fun ScramblingJobData.compute(statusBackend: StatusBackend): Pair { - val wcif = WCIFScrambleMatcher.fillScrambleSetsAsync(request.extendedWcif) { evt, _ -> - statusBackend.onProgress(evt.key) + val wcif = WCIFScrambleMatcher.fillScrambleSetsAsync(request.wcif) { evt, _ -> + statusBackend.onProgress(evt.id) } return scrambledToResult(this, wcif, statusBackend) } override fun getTargetState(data: ScramblingJobData) = - WCIFScrambleMatcher.getScrambleCountsPerEvent(data.request.extendedWcif) + extraTargetData + WCIFScrambleMatcher.getScrambleCountsPerEvent(data.request.wcif) + extraTargetData override fun getResultMarker(data: ScramblingJobData) = data.request.wcif.id @@ -78,7 +80,8 @@ class WcifHandler(val environmentConfig: ServerEnvironmentConfig) : RouteHandler const val MAX_SCRAMBLE_SET_COUNT = 26 fun validateRequest(req: WcifScrambleRequest): Boolean { - val checkMultiCubes = req.multiCubes?.requestedScrambles ?: 0 + val multiCubesExtension = req.wcif.events.find { it.id == EventData.THREE_MULTI_BLD.id }?.findExtension() + val checkMultiCubes = multiCubesExtension?.requestedScrambles ?: 0 if (checkMultiCubes > MAX_MULTI_CUBES) { BadWcifParameterException.error("The maximum amount of MBLD cubes is $MAX_MULTI_CUBES") @@ -129,11 +132,8 @@ class WcifHandler(val environmentConfig: ServerEnvironmentConfig) : RouteHandler val pdfPassword = req.request.pdfPassword val zipPassword = req.request.zipPassword - val fmcTranslations = req.request.fmcLanguages?.languageTags.orEmpty() - .mapNotNull { Translate.LOCALES_BY_LANG_TAG[it] } - // TODO GB allow building ZIPs in languages other than English? - val zip = WCIFDataBuilder.wcifToZip(wcif, pdfPassword, versionTag, Translate.DEFAULT_LOCALE, fmcTranslations, generationDate, generationUrl) + val zip = WCIFDataBuilder.wcifToZip(wcif, pdfPassword, versionTag, Translate.DEFAULT_LOCALE, generationDate, generationUrl) val bytes = zip.compress(zipPassword) backend.onProgress(WORKER_PDF) diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/routing/frontend/PuzzleDrawingHandler.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/routing/frontend/PuzzleDrawingHandler.kt new file mode 100644 index 000000000..28b7006b6 --- /dev/null +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/routing/frontend/PuzzleDrawingHandler.kt @@ -0,0 +1,67 @@ +package org.worldcubeassociation.tnoodle.server.webscrambles.routing.frontend + +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import org.worldcubeassociation.tnoodle.server.RouteHandler +import org.worldcubeassociation.tnoodle.server.model.PuzzleData +import org.worldcubeassociation.tnoodle.server.webscrambles.serial.FrontendScrambleAndImage +import org.worldcubeassociation.tnoodle.svglite.Color + +object PuzzleDrawingHandler : RouteHandler { + override fun install(router: Route) { + router.route("puzzle") { + route("{puzzleId}") { + get("colors") { + val puzzle = call.puzzleData + ?: return@get call.respond(HttpStatusCode.NotFound) + + val defaultColorScheme = + puzzle.scrambler.defaultColorScheme.mapValues { "#${it.value.toHex()}" } + + call.respond(defaultColorScheme) + } + + post("scramble") { + val puzzle = call.puzzleData + ?: return@post call.respond(HttpStatusCode.NotFound) + + val colorScheme = call.receiveColorScheme() + + val randomScramble = puzzle.generateScramble() + val scrambledPuzzleSvg = puzzle.scramblerWithCache.drawScramble(randomScramble, colorScheme) + + val frontendData = FrontendScrambleAndImage(randomScramble, scrambledPuzzleSvg.toString()) + + call.respond(frontendData) + } + + post("svg") { + val puzzle = call.puzzleData + ?: return@post call.respond(HttpStatusCode.NotFound) + + val colorScheme = call.receiveColorScheme() + val solvedPuzzleSvg = puzzle.scramblerWithCache.drawScramble(null, colorScheme) + + call.respondText(solvedPuzzleSvg.toString(), ContentType.Image.SVG) + } + } + } + } + + private val ApplicationCall.puzzleData + get(): PuzzleData? { + val puzzleId = parameters["puzzleId"] + + return PuzzleData.WCA_PUZZLES[puzzleId] + } + + private suspend fun ApplicationCall.receiveColorScheme(): HashMap { + val rawColorScheme = receive>() + val colorScheme = rawColorScheme.mapValues { Color(it.value) } + + return HashMap(colorScheme) + } +} diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/serial/EventFrontendData.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/serial/EventFrontendData.kt index ce9e6363c..59c438207 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/serial/EventFrontendData.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/serial/EventFrontendData.kt @@ -8,6 +8,8 @@ import org.worldcubeassociation.tnoodle.server.model.EventData data class EventFrontendData( val id: String, val name: String, + @SerialName("puzzle_id") val puzzleId: String, + @SerialName("puzzle_group_id") val puzzleGroupId: String?, @SerialName("format_ids") val formatIds: List, @SerialName("can_change_time_limit") val canChangeTimeLimit: Boolean, @SerialName("is_timed_event") val isTimedEvent: Boolean, @@ -16,9 +18,13 @@ data class EventFrontendData( ) { companion object { fun fromDataModel(event: EventData): EventFrontendData { + val rootScrambler = event.scrambler.rootScrambler + return EventFrontendData( - event.key, + event.id, event.description, + rootScrambler.id, + rootScrambler.groupId, event.legalFormats.map { it.key }, event !in EventData.ONE_HOUR_EVENTS, event !in EventData.ONE_HOUR_EVENTS, diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/serial/FrontendScrambleAndImage.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/serial/FrontendScrambleAndImage.kt new file mode 100644 index 000000000..05cbfa192 --- /dev/null +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/serial/FrontendScrambleAndImage.kt @@ -0,0 +1,9 @@ +package org.worldcubeassociation.tnoodle.server.webscrambles.serial + +import kotlinx.serialization.Serializable + +@Serializable +data class FrontendScrambleAndImage( + val scramble: String, + val svgImage: String +) diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/serial/WcifScrambleRequest.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/serial/WcifScrambleRequest.kt index 4b9ad15a4..c5c3b3c6c 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/serial/WcifScrambleRequest.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/serial/WcifScrambleRequest.kt @@ -1,31 +1,11 @@ package org.worldcubeassociation.tnoodle.server.webscrambles.serial import kotlinx.serialization.Serializable -import org.worldcubeassociation.tnoodle.server.model.EventData -import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.WCIFScrambleMatcher import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model.Competition -import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model.extension.FmcLanguagesExtension -import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model.extension.MultiScrambleCountExtension -import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model.extension.TNoodleStatusExtension @Serializable data class WcifScrambleRequest( val wcif: Competition, val zipPassword: String? = null, val pdfPassword: String? = null, - val multiCubes: MultiScrambleCountExtension? = null, - val fmcLanguages: FmcLanguagesExtension? = null, - val frontendStatus: TNoodleStatusExtension? = null -) { - val extendedWcif by lazy { compileExtendedWcif() } - - private fun compileExtendedWcif(): Competition { - val optionalExtensions = listOfNotNull( - multiCubes?.to(EventData.THREE_MULTI_BLD), - fmcLanguages?.to(EventData.THREE_FM) - ).toMap() - - val statusWcif = wcif.copy(extensions = wcif.withExtension(frontendStatus)) - return WCIFScrambleMatcher.installExtensions(statusWcif, optionalExtensions) - } -} +) diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/WCIFDataBuilder.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/WCIFDataBuilder.kt index 6dfb961be..e77da1600 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/WCIFDataBuilder.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/WCIFDataBuilder.kt @@ -1,15 +1,18 @@ package org.worldcubeassociation.tnoodle.server.webscrambles.wcif import org.worldcubeassociation.tnoodle.server.model.EventData +import org.worldcubeassociation.tnoodle.server.webscrambles.Translate import org.worldcubeassociation.tnoodle.server.webscrambles.pdf.FmcSolutionSheet import org.worldcubeassociation.tnoodle.server.webscrambles.pdf.GeneralScrambleSheet import org.worldcubeassociation.tnoodle.server.webscrambles.pdf.ScrambleSheet import org.worldcubeassociation.tnoodle.server.webscrambles.pdf.model.Document +import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.WCIFDataBuilder.pickWatermarkPhrase import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model.* import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model.extension.* import org.worldcubeassociation.tnoodle.server.webscrambles.zip.ScrambleZip import org.worldcubeassociation.tnoodle.server.webscrambles.zip.model.ZipArchive import org.worldcubeassociation.tnoodle.server.webscrambles.zip.util.StringUtil.withUniqueTitles +import org.worldcubeassociation.tnoodle.svglite.Color import java.time.LocalDateTime import java.util.* @@ -23,14 +26,11 @@ object WCIFDataBuilder { versionTag: String, locale: Locale ): List { - val frontendStatus = findExtension() - val frontendWatermark = frontendStatus?.pickWatermarkPhrase() - return events.flatMap { e -> e.rounds.flatMap { r -> r.scrambleSets.withIndex() .flatMap { (groupNum, scrSet) -> - scrambleSetToDocuments(this, e, r, groupNum, scrSet, versionTag, locale, frontendWatermark) + scrambleSetToDocuments(this, e, r, groupNum, scrSet, versionTag, locale) } } } @@ -44,8 +44,13 @@ object WCIFDataBuilder { scrambleSet: ScrambleSet, versionTag: String, locale: Locale, - watermark: String? = null ): List { + val frontendStatus = comp.findExtension() + val watermark = frontendStatus?.pickWatermarkPhrase() + + val frontendColorScheme = event.findExtension() + val colorScheme = frontendColorScheme?.colorScheme + val baseCode = round.idCode.copyParts(groupNumber = group) when (event.eventModel) { @@ -59,7 +64,16 @@ object WCIFDataBuilder { extraScrambles = listOf() ) - makeGenericSheet(comp, round, attemptScrambles, attemptCode, versionTag, locale, watermark) + makeGenericSheet( + comp, + round, + attemptScrambles, + attemptCode, + versionTag, + locale, + watermark, + colorScheme + ) } } @@ -76,13 +90,14 @@ object WCIFDataBuilder { attemptCode, hasGroupId, locale, - watermark + watermark, + colorScheme ) } } else -> { - val genericSheet = makeGenericSheet(comp, round, scrambleSet, baseCode, versionTag, locale, watermark) + val genericSheet = makeGenericSheet(comp, round, scrambleSet, baseCode, versionTag, locale, watermark, colorScheme) return listOf(genericSheet) } } @@ -102,9 +117,11 @@ object WCIFDataBuilder { activityCode: ActivityCode, versionTag: String, locale: Locale, - watermark: String? + watermark: String?, + colorScheme: Map?, ): GeneralScrambleSheet { val hasGroupId = round.scrambleSetCount > 1 + return GeneralScrambleSheet( scrambleSet, versionTag, @@ -112,7 +129,8 @@ object WCIFDataBuilder { activityCode, hasGroupId, locale, - watermark + watermark, + colorScheme ) } @@ -133,7 +151,6 @@ object WCIFDataBuilder { pdfPassword: String?, versionTag: String, locale: Locale, - fmcTranslations: List, generationDate: LocalDateTime, generationUrl: String ): ZipArchive { @@ -143,6 +160,11 @@ object WCIFDataBuilder { val frontendStatus = wcif.findExtension() val frontendWatermark = frontendStatus?.pickWatermarkPhrase() + val fmcTranslations = wcif.events.find { it.id == EventData.THREE_FM.id } + ?.findExtension() + ?.languageTags.orEmpty() + .mapNotNull { Translate.LOCALES_BY_LANG_TAG[it] } + val scrambleZip = ScrambleZip(wcif, namedSheets, fmcTranslations, frontendWatermark) return scrambleZip.assemble(generationDate, versionTag, pdfPassword, generationUrl) diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/WCIFScrambleMatcher.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/WCIFScrambleMatcher.kt index a030d4621..56eb4e874 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/WCIFScrambleMatcher.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/WCIFScrambleMatcher.kt @@ -188,9 +188,6 @@ object WCIFScrambleMatcher { // EXTENSION HANDLING ----- - fun installExtensions(wcif: Competition, ext: Map) = - ext.entries.fold(wcif) { acc, e -> installExtensionForEvents(acc, e.key, e.value) } - fun installExtensionForEvents(wcif: Competition, ext: ExtensionBuilder, event: EventData): Competition { fun installRoundExtension(e: Event): Event { val extendedRounds = e.rounds.map { r -> @@ -201,7 +198,7 @@ object WCIFScrambleMatcher { } val extendedEvents = wcif.events.map { e -> - e.takeUnless { it.id == event.key } + e.takeUnless { it.id == event.id } ?: installRoundExtension(e) } diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/ActivityCode.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/ActivityCode.kt index d014ee6e7..ae8ad6d24 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/ActivityCode.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/ActivityCode.kt @@ -113,7 +113,7 @@ data class ActivityCode(val activityCodeString: String) : EventIdProvider { } fun compile(event: EventData, round: Int? = null, group: Int? = null, attempt: Int? = null) = - compile(event.key, round, group, attempt) + compile(event.id, round, group, attempt) override fun encodeInstance(instance: ActivityCode) = instance.activityCodeString override fun makeInstance(deserialized: String) = ActivityCode(deserialized) diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/Competition.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/Competition.kt index cc9852f72..4f8436a3e 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/Competition.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/Competition.kt @@ -4,4 +4,13 @@ import kotlinx.serialization.Serializable import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model.extension.ExtensionProvider @Serializable -data class Competition(val formatVersion: String, val id: String, val name: String, val shortName: String, val persons: List, val events: List, val schedule: Schedule, override val extensions: List = emptyList()) : ExtensionProvider() +data class Competition( + val formatVersion: String, + val id: String, + val name: String, + val shortName: String, + val persons: List, + val events: List, + val schedule: Schedule, + override val extensions: List = emptyList() +) : ExtensionProvider() diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/Event.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/Event.kt index e836cfcc7..cafbf1976 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/Event.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/Event.kt @@ -1,10 +1,15 @@ package org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model import kotlinx.serialization.Serializable +import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model.extension.ExtensionProvider import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.provider.EventIdProvider @Serializable -data class Event(val id: String, val rounds: List) : EventIdProvider { +data class Event( + val id: String, + val rounds: List, + override val extensions: List = emptyList() +) : ExtensionProvider(), EventIdProvider { override val eventId: String get() = id } diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/extension/ExtensionBuilders.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/extension/ExtensionBuilders.kt index bdf4bc3ca..e4a6c8298 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/extension/ExtensionBuilders.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/wcif/model/extension/ExtensionBuilders.kt @@ -4,8 +4,10 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject +import org.worldcubeassociation.tnoodle.server.serial.Colorizer import org.worldcubeassociation.tnoodle.server.serial.JsonConfig import org.worldcubeassociation.tnoodle.server.webscrambles.wcif.model.Extension +import org.worldcubeassociation.tnoodle.svglite.Color @Serializable sealed class ExtensionBuilder { @@ -92,3 +94,15 @@ data class TNoodleStatusExtension(val isStaging: Boolean, val isManual: Boolean, const val SPEC_URL = "TODO" } } + +@Serializable +@SerialName(ColorSchemeExtension.ID) +data class ColorSchemeExtension(val colorScheme: Map) : ExtensionBuilder() { + override val id get() = ID + override val specUrl get() = SPEC_URL + + companion object { + const val ID = "org.worldcubeassociation.tnoodle.ColorScheme" + const val SPEC_URL = "TODO" + } +} diff --git a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/zip/PrintingFolder.kt b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/zip/PrintingFolder.kt index 9a3f88bcc..4a16b5b6e 100644 --- a/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/zip/PrintingFolder.kt +++ b/webscrambles/src/main/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/zip/PrintingFolder.kt @@ -54,7 +54,7 @@ data class PrintingFolder( for ((uniq, sheet) in uniqueTitles) { if (sheet is FewestMovesSheet) { - val defaultCutoutSheet = FmcCutoutSheet(sheet.scramble, sheet.totalAttemptsNum, sheet.scrambleSetId, competitionName, sheet.activityCode, sheet.hasGroupId, Translate.DEFAULT_LOCALE, sheet.watermark) + val defaultCutoutSheet = FmcCutoutSheet(sheet.scramble, sheet.totalAttemptsNum, sheet.scrambleSetId, competitionName, sheet.activityCode, sheet.hasGroupId, Translate.DEFAULT_LOCALE, sheet.watermark, sheet.colorScheme) file("$uniq - Scramble Cutout Sheet.pdf", defaultCutoutSheet.render(pdfPassword)) if (fmcTranslations.isNotEmpty()) { @@ -62,11 +62,11 @@ data class PrintingFolder( for (locale in fmcTranslations) { folder(locale.toLanguageTag()) { // fewest moves regular sheet - val localPrintingSheet = FmcSolutionSheet(sheet.scramble, sheet.totalAttemptsNum, sheet.scrambleSetId, competitionName, sheet.activityCode, sheet.hasGroupId, locale, sheet.watermark) + val localPrintingSheet = FmcSolutionSheet(sheet.scramble, sheet.totalAttemptsNum, sheet.scrambleSetId, competitionName, sheet.activityCode, sheet.hasGroupId, locale, sheet.watermark, sheet.colorScheme) file("$uniq.pdf", localPrintingSheet.render(pdfPassword)) // scramble cutout sheet - val localCutoutSheet = FmcCutoutSheet(sheet.scramble, sheet.totalAttemptsNum, sheet.scrambleSetId, competitionName, sheet.activityCode, sheet.hasGroupId, locale, sheet.watermark) + val localCutoutSheet = FmcCutoutSheet(sheet.scramble, sheet.totalAttemptsNum, sheet.scrambleSetId, competitionName, sheet.activityCode, sheet.hasGroupId, locale, sheet.watermark, sheet.colorScheme) file("$uniq Scramble Cutout Sheet.pdf", localCutoutSheet.render(pdfPassword)) } } diff --git a/webscrambles/src/test/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/APIIntegrationTest.kt b/webscrambles/src/test/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/APIIntegrationTest.kt index f683cb5c5..275b252ca 100644 --- a/webscrambles/src/test/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/APIIntegrationTest.kt +++ b/webscrambles/src/test/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/APIIntegrationTest.kt @@ -1,7 +1,6 @@ package org.worldcubeassociation.tnoodle.server.webscrambles import kotlinx.coroutines.* -import kotlinx.serialization.decodeFromString import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test import org.junit.jupiter.api.function.ThrowingSupplier @@ -46,7 +45,7 @@ class APIIntegrationTest { val computationTime = measureTimeMillis { println("[$it] Single PDF rendered successfully. On to compiling the ZIP…") - val completeZip = WCIFDataBuilder.wcifToZip(scrambledWcif, null, "JUnit-Test", Translate.DEFAULT_LOCALE, emptyList(), generationDate, "https://test.local") + val completeZip = WCIFDataBuilder.wcifToZip(scrambledWcif, null, "JUnit-Test", Translate.DEFAULT_LOCALE, generationDate, "https://test.local") Assertions.assertTrue(completeZip.allFiles.isNotEmpty()) val compiledZip = Assertions.assertDoesNotThrow(ThrowingSupplier { completeZip.compress() }) Assertions.assertTrue(compiledZip.isNotEmpty()) diff --git a/webscrambles/src/test/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/PdfRenderingTest.kt b/webscrambles/src/test/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/PdfRenderingTest.kt index 9ba31b7ad..b56a5f964 100644 --- a/webscrambles/src/test/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/PdfRenderingTest.kt +++ b/webscrambles/src/test/kotlin/org/worldcubeassociation/tnoodle/server/webscrambles/PdfRenderingTest.kt @@ -55,7 +55,7 @@ class PdfRenderingTest { @Test fun `test that 3+2 scrambles get displayed on one page`() { for (event in EventData.values()) { - println("Rendering 3+2 layout for ${event.key}") + println("Rendering 3+2 layout for ${event.id}") repeat(SCRAMBLE_REPETITIONS) { val sheet = getGenericScrambleSheet(event, 3, 2) @@ -67,7 +67,7 @@ class PdfRenderingTest { @Test fun `test that 5+2 scrambles get displayed on one page`() { for (event in EventData.values()) { - println("Rendering 5+2 layout for ${event.key}") + println("Rendering 5+2 layout for ${event.id}") repeat(SCRAMBLE_REPETITIONS) { val sheet = getGenericScrambleSheet(event, 5, 2) @@ -79,7 +79,7 @@ class PdfRenderingTest { @Test fun `test that 7+0 scrambles get displayed on one page`() { for (event in EventData.values()) { - println("Rendering 7+0 layout for ${event.key}") + println("Rendering 7+0 layout for ${event.id}") repeat(SCRAMBLE_REPETITIONS) { val sheet = getGenericScrambleSheet(event, 7, 0)