diff --git a/fission/src/systems/match_mode/DefaultMatchModeConfigs.ts b/fission/src/systems/match_mode/DefaultMatchModeConfigs.ts index 3619774e6c..43cc173a24 100644 --- a/fission/src/systems/match_mode/DefaultMatchModeConfigs.ts +++ b/fission/src/systems/match_mode/DefaultMatchModeConfigs.ts @@ -12,7 +12,7 @@ class DefaultMatchModeConfigs { teleopTime: 135, endgameTime: 20, ignoreRotation: true, - maxHeight: Infinity, + maxHeight: Number.MAX_SAFE_INTEGER, heightLimitPenalty: 0, sideMaxExtension: convertFeetToMeters(1.5), sideExtensionPenalty: 0, @@ -60,9 +60,9 @@ class DefaultMatchModeConfigs { teleopTime: 15, endgameTime: 5, ignoreRotation: true, - maxHeight: Infinity, + maxHeight: Number.MAX_SAFE_INTEGER, heightLimitPenalty: 0, - sideMaxExtension: Infinity, + sideMaxExtension: Number.MAX_SAFE_INTEGER, sideExtensionPenalty: 0, } } @@ -76,9 +76,9 @@ class DefaultMatchModeConfigs { teleopTime: 135, endgameTime: 20, ignoreRotation: true, - maxHeight: Infinity, + maxHeight: Number.MAX_SAFE_INTEGER, heightLimitPenalty: 2, - sideMaxExtension: Infinity, + sideMaxExtension: Number.MAX_SAFE_INTEGER, sideExtensionPenalty: 2, } } diff --git a/fission/src/ui/components/StyledComponents.tsx b/fission/src/ui/components/StyledComponents.tsx index 1280851435..80da91eff9 100644 --- a/fission/src/ui/components/StyledComponents.tsx +++ b/fission/src/ui/components/StyledComponents.tsx @@ -37,7 +37,7 @@ import { import { GiSteeringWheel } from "react-icons/gi" import { GrConnect } from "react-icons/gr" import { HiDownload } from "react-icons/hi" -import { IoCheckmark, IoPencil, IoPeople, IoTrashBin } from "react-icons/io5" +import { IoCheckmark, IoPencil, IoPeople, IoPlayOutline, IoTrashBin } from "react-icons/io5" import Label from "./Label" export class SynthesisIcons { @@ -63,6 +63,7 @@ export class SynthesisIcons { public static readonly CONNECT = public static readonly INFO = public static readonly BUG = + public static readonly PLAY = /** Large icons: used for icon buttons */ public static readonly DELETE_LARGE = @@ -75,6 +76,7 @@ export class SynthesisIcons { public static readonly LEFT_ARROW_LARGE = public static readonly BUG_LARGE = public static readonly XMARK_LARGE = + public static readonly PLAY_LARGE = public static readonly OPEN_HUD_ICON = ( boolean + message: string +} + +interface FormField { + value: T + error: boolean + errorText: string + rules: ValidationRule[] +} + +type FormState = Record + +type FieldConfig = { + defaultValue: string | number | boolean + rules: ValidationRule[] + type?: "text" | "number" | "decimal" | "checkbox" +} + +// Validation rules +const VALIDATION_RULES = { + required: (message = "This field is required"): ValidationRule => ({ + validate: (value: unknown) => { + if (typeof value === "string") return value.trim() !== "" + return value != null + }, + message, + }), + + nonNegativeInteger: (message = "Must be a non-negative whole number"): ValidationRule => ({ + validate: (value: unknown) => { + if (typeof value !== "string") return false + const num = parseInt(value, 10) + return !isNaN(num) && num >= 0 && Number.isInteger(num) && value === num.toString() + }, + message, + }), + + nonNegativeNumber: (message = "Must be a non-negative number"): ValidationRule => ({ + validate: (value: unknown) => { + if (typeof value !== "string") return false + const num = parseFloat(value) + return !isNaN(num) && num >= 0 + }, + message, + }), +} + +const fallbackConfig = DefaultMatchModeConfigs.fallbackValues() + +// Field configurations +const FIELD_CONFIGS: Record = { + name: { + defaultValue: "Input Config Name", + rules: [VALIDATION_RULES.required("Name is required")], + type: "text", + }, + autonomousTime: { + defaultValue: fallbackConfig.autonomousTime, + rules: [VALIDATION_RULES.nonNegativeInteger("Autonomous time must be a non-negative whole number")], + type: "number", + }, + teleopTime: { + defaultValue: fallbackConfig.teleopTime, + rules: [VALIDATION_RULES.nonNegativeInteger("Teleop time must be a non-negative whole number")], + type: "number", + }, + endgameTime: { + defaultValue: fallbackConfig.endgameTime, + rules: [VALIDATION_RULES.nonNegativeInteger("Endgame time must be a non-negative whole number")], + type: "number", + }, + ignoreRotation: { + defaultValue: fallbackConfig.ignoreRotation, + rules: [], + type: "checkbox", + }, + enableHeightPenalty: { + defaultValue: false, + rules: [], + type: "checkbox", + }, + maxHeight: { + defaultValue: fallbackConfig.maxHeight === Number.MAX_SAFE_INTEGER ? 1.2 : fallbackConfig.maxHeight, + rules: [VALIDATION_RULES.nonNegativeNumber("Max height must be a non-negative number")], + type: "decimal", + }, + heightLimitPenalty: { + defaultValue: fallbackConfig.heightLimitPenalty === 0 ? 2 : fallbackConfig.heightLimitPenalty, + rules: [VALIDATION_RULES.nonNegativeInteger("Height penalty must be a non-negative whole number")], + type: "number", + }, + enableSideExtensionPenalty: { + defaultValue: false, + rules: [], + type: "checkbox", + }, + sideMaxExtension: { + defaultValue: + fallbackConfig.sideMaxExtension === Number.MAX_SAFE_INTEGER ? 0.5 : fallbackConfig.sideMaxExtension, + rules: [VALIDATION_RULES.nonNegativeNumber("Side max extension must be a non-negative number")], + type: "decimal", + }, + sideExtensionPenalty: { + defaultValue: fallbackConfig.sideExtensionPenalty === 0 ? 2 : fallbackConfig.sideExtensionPenalty, + rules: [VALIDATION_RULES.nonNegativeInteger("Side extension penalty must be a non-negative whole number")], + type: "number", + }, +} + +// Initial form state factory +const createInitialFormState = (): FormState => { + const formState: FormState = {} + + Object.entries(FIELD_CONFIGS).forEach(([fieldName, config]) => { + // Preserve boolean values for checkbox fields, convert others to strings + let value: unknown + if (config.type === "checkbox") { + value = config.defaultValue + } else { + value = typeof config.defaultValue === "string" ? config.defaultValue : config.defaultValue.toString() + } + + formState[fieldName] = { + value, + error: false, + errorText: "", + rules: config.rules, + } + }) + + return formState +} + +const CreateNewMatchModeConfigPanel: React.FC> = ({ panel }) => { + const { configureScreen, openPanel, closePanel } = useUIContext() + const [formState, setFormState] = useState(createInitialFormState) + + const validateField = useCallback((field: FormField, value: unknown): { error: boolean; errorText: string } => { + for (const rule of field.rules) { + if (!rule.validate(value)) { + return { error: true, errorText: rule.message } + } + } + return { error: false, errorText: "" } + }, []) + + const updateField = useCallback( + (fieldName: string, value: unknown) => { + setFormState(prev => { + const field = prev[fieldName] + const validation = validateField(field, value) + + return { + ...prev, + [fieldName]: { + ...field, + value, + error: validation.error, + errorText: validation.errorText, + }, + } + }) + }, + [validateField] + ) + + const handleFieldChange = useCallback( + (fieldName: string, isCheckbox = false) => + (event: React.ChangeEvent) => { + const value = isCheckbox ? event.target.checked : event.target.value + updateField(fieldName, value) + }, + [updateField] + ) + + // Prevent non-integer input for number fields + const preventNonIntegerKeys = useCallback((e: React.KeyboardEvent) => { + if (e.key === "." || e.key === "-" || e.key === "+" || e.key === "e" || e.key === "E") { + e.preventDefault() + } + }, []) + + const isFormValid = useCallback(() => { + return Object.values(formState).every(field => !field.error) + }, [formState]) + + const renderField = useCallback( + (fieldName: string, label: string, helperText?: string, conditionalOn?: string) => { + const field = formState[fieldName] + const config = FIELD_CONFIGS[fieldName] + + // Check if this field should be conditionally rendered + if (conditionalOn && !formState[conditionalOn]?.value) { + return null + } + + if (config.type === "checkbox") { + return ( + updateField(fieldName, value)} + label={""} + /> + } + label={label} + /> + ) + } + + const isNumber = config.type === "number" + const isDecimal = config.type === "decimal" + const isNumericInput = isNumber || isDecimal + + return ( + + ) + }, + [formState, handleFieldChange, preventNonIntegerKeys, updateField] + ) + + const createConfigFromForm = useCallback((): MatchModeConfig => { + return { + id: crypto.randomUUID(), + name: (formState.name.value as string).trim(), + isDefault: false, + autonomousTime: parseInt(formState.autonomousTime.value as string, 10), + teleopTime: parseInt(formState.teleopTime.value as string, 10), + endgameTime: parseInt(formState.endgameTime.value as string, 10), + ignoreRotation: formState.ignoreRotation.value as boolean, + maxHeight: formState.enableHeightPenalty.value + ? parseFloat(formState.maxHeight.value as string) + : Number.MAX_SAFE_INTEGER, + heightLimitPenalty: formState.enableHeightPenalty.value + ? parseFloat(formState.heightLimitPenalty.value as string) + : 0, + sideMaxExtension: formState.enableSideExtensionPenalty.value + ? parseFloat(formState.sideMaxExtension.value as string) + : Number.MAX_SAFE_INTEGER, + sideExtensionPenalty: formState.enableSideExtensionPenalty.value + ? parseFloat(formState.sideExtensionPenalty.value as string) + : 0, + } + }, [formState]) + + const downloadConfig = useCallback(() => { + const config = createConfigFromForm() + + // Create a blob with the JSON data + const jsonString = JSON.stringify(config, null, 2) + const blob = new Blob([jsonString], { type: "application/json" }) + + // Create a download link + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${config.name.replace(/[^a-z0-9]/gi, "_").toLowerCase()}_match_mode_config.json` + document.body.appendChild(link) + link.click() + + // Cleanup + document.body.removeChild(link) + URL.revokeObjectURL(url) + }, [createConfigFromForm]) + + useEffect(() => { + configureScreen( + panel!, + { + title: "Create New Match Mode Config", + cancelText: "Cancel", + acceptText: "Save", + hideAccept: !isFormValid(), + }, + { + onBeforeAccept: () => { + const config = createConfigFromForm() + const validatedConfig = validateAndNormalizeMatchModeConfig(config) + + if (!validatedConfig) { + return "Validation failed" + } + + const customConfigs = window.localStorage.getItem("match-mode-configs") + if (customConfigs) { + const customConfigsArray = JSON.parse(customConfigs) + customConfigsArray.push(validatedConfig) + window.localStorage.setItem("match-mode-configs", JSON.stringify(customConfigsArray)) + } else { + window.localStorage.setItem("match-mode-configs", JSON.stringify([validatedConfig])) + } + + setTimeout(async () => { + const { default: MatchModeConfigPanelComponent } = await import("./MatchModeConfigPanel") + openPanel(MatchModeConfigPanelComponent, undefined) + closePanel(panel!.id, CloseType.Overwrite) + }, 0) + + return validatedConfig + }, + } + ) + }, [isFormValid, createConfigFromForm, configureScreen, panel, openPanel, closePanel]) + + // Field groups for organized rendering + const fieldGroups: Array<{ + title: string + fields: Array<{ + name: string + label: string + helperText?: string + conditionalOn?: string + }> + }> = [ + { + title: "Basic Configuration", + fields: [{ name: "name", label: "Configuration Name" }], + }, + { + title: "Timing Configuration", + fields: [ + { name: "autonomousTime", label: "Autonomous Time (seconds)" }, + { name: "teleopTime", label: "Teleop Time (seconds)" }, + { name: "endgameTime", label: "Endgame Time (seconds)" }, + ], + }, + { + title: "Robot Constraints", + fields: [ + { name: "ignoreRotation", label: "Ignore Robot Rotation for Height Calculations" }, + { name: "enableHeightPenalty", label: "Enable Height Penalty" }, + { + name: "maxHeight", + label: "Maximum Height (meters)", + helperText: "Height limit for the robot", + conditionalOn: "enableHeightPenalty", + }, + { + name: "heightLimitPenalty", + label: "Height Penalty (points)", + conditionalOn: "enableHeightPenalty", + }, + { name: "enableSideExtensionPenalty", label: "Enable Side Extension Penalty" }, + { + name: "sideMaxExtension", + label: "Side Max Extension (meters)", + helperText: "Maximum side extension limit for the robot", + conditionalOn: "enableSideExtensionPenalty", + }, + { + name: "sideExtensionPenalty", + label: "Side Extension Penalty (points)", + conditionalOn: "enableSideExtensionPenalty", + }, + ], + }, + ] + + return ( + + + {fieldGroups.map((group, groupIndex) => ( + + + + {group.title} + + {group.fields.map(field => + renderField(field.name, field.label, field.helperText, field.conditionalOn) + )} + + {groupIndex < fieldGroups.length - 1 && } + + ))} + + + + {/* Download Button */} + + + + + + ) +} + +export default CreateNewMatchModeConfigPanel diff --git a/fission/src/ui/panels/configuring/MatchModeConfigPanel.tsx b/fission/src/ui/panels/configuring/MatchModeConfigPanel.tsx index 9c14cee51d..255980707b 100644 --- a/fission/src/ui/panels/configuring/MatchModeConfigPanel.tsx +++ b/fission/src/ui/panels/configuring/MatchModeConfigPanel.tsx @@ -13,6 +13,7 @@ import Label from "@/ui/components/Label" import type { PanelImplProps } from "@/ui/components/Panel" import { NegativeButton, PositiveButton, SynthesisIcons } from "@/ui/components/StyledComponents" import { CloseType, useUIContext } from "@/ui/helpers/UIProviderHelpers" +import CreateNewMatchModeConfigPanel from "./CreateNewMatchModeConfigPanel" /** * Configuration for match mode rules and timing. @@ -85,6 +86,57 @@ const props: Readonly<{ id: keyof MatchModeConfig; expectedType: string; require { id: "sideExtensionPenalty", expectedType: "number", required: false }, ] +export const validateAndNormalizeMatchModeConfig = (config: unknown): MatchModeConfig | null => { + // Type guard to check if config is an object + if (typeof config !== "object" || config === null) { + console.error("Match mode config validation failed: config must be an object") + globalAddToast("error", "Invalid Match Mode Config", "Configuration must be an object") + return null + } + const configObj = config as Record + + const typeError = (id: string, expectedType?: string) => { + const errorMessage = expectedType ? `must be a ${expectedType}` : "is required" + console.error(`Match mode config validation failed: the '${id}' field ${errorMessage}`) + globalAddToast("error", "Invalid Match Mode Config", `The '${id}' field ${errorMessage}`) + } + + function checkValidity(configObj: Record): configObj is Partial { + for (const prop of props) { + if (configObj[prop.id] == undefined) { + if (prop.required) { + typeError(prop.id) + return false + } + } else if (typeof configObj[prop.id] != prop.expectedType) { + if (prop.required) { + typeError(prop.id, prop.expectedType) + return false + } else { + globalAddToast( + "warning", + "Invalid Match Mode Config", + `The '${prop.id}' field must be a ${prop.expectedType}, ignoring ${prop.id} field` + ) + } + } + } + return true + } + + if (!checkValidity(configObj)) { + return null + } + + // If validation passes, use the default values in any missing fields + const normalizedConfig = { + ...DefaultMatchModeConfigs.fallbackValues(), + ...configObj, + } + normalizedConfig.isDefault = false + return normalizedConfig +} + interface ItemCardProps { id: string name: string @@ -108,14 +160,14 @@ const ItemCard: React.FC = ({ id, name, primaryOnClick, secondary {secondaryOnClick && ( {SynthesisIcons.DELETE_LARGE} )} - {SynthesisIcons.SELECT_LARGE} + {SynthesisIcons.PLAY_LARGE} ) } const MatchModeConfigPanel: React.FC> = ({ panel }) => { - const { closePanel, openModal, configureScreen } = useUIContext() + const { openPanel, closePanel, openModal, configureScreen } = useUIContext() const [matchModeConfigs, setMatchModeConfigs] = useState([]) const [useSpawnPositions, setUseSpawnPositions] = useState(false) @@ -187,11 +239,6 @@ const MatchModeConfigPanel: React.FC> = ({ panel }) = // Only save custom configs to local storage const customConfigs = updatedConfigs.filter(c => !c.isDefault) window.localStorage.setItem("match-mode-configs", JSON.stringify(customConfigs)) - globalAddToast( - "info", - "Match Mode Config Deleted", - `Successfully deleted "${config.name}"` - ) } : undefined } @@ -209,57 +256,6 @@ const MatchModeConfigPanel: React.FC> = ({ panel }) = } } - const validateAndNormalizeMatchModeConfig = (config: unknown): MatchModeConfig | null => { - // Type guard to check if config is an object - if (typeof config !== "object" || config === null) { - console.error("Match mode config validation failed: config must be an object") - globalAddToast("error", "Invalid Match Mode Config", "Configuration must be an object") - return null - } - const configObj = config as Record - - const typeError = (id: string, expectedType?: string) => { - const errorMessage = expectedType ? `must be a ${expectedType}` : "is required" - console.error(`Match mode config validation failed: the '${id}' field ${errorMessage}`) - globalAddToast("error", "Invalid Match Mode Config", `The '${id}' field ${errorMessage}`) - } - - function checkValidity(configObj: Record): configObj is Partial { - for (const prop of props) { - if (configObj[prop.id] == undefined) { - if (prop.required) { - typeError(prop.id) - return false - } - } else if (typeof configObj[prop.id] != prop.expectedType) { - if (prop.required) { - typeError(prop.id, prop.expectedType) - return false - } else { - globalAddToast( - "warning", - "Invalid Match Mode Config", - `The '${prop.id}' field must be a ${prop.expectedType}, ignoring ${prop.id} field` - ) - } - } - } - return true - } - - if (!checkValidity(configObj)) { - return null - } - - // If validation passes, use the default values in any missing fields - const normalizedConfig = { - ...DefaultMatchModeConfigs.fallbackValues(), - ...configObj, - } - normalizedConfig.isDefault = false - return normalizedConfig - } - const handleFileUpload = async (file: File) => { // Check if it's a JSON file if (!file.name.toLowerCase().endsWith(".json")) { @@ -314,6 +310,12 @@ const MatchModeConfigPanel: React.FC> = ({ panel }) = // Reset the input value so the same file can be selected again e.target.value = "" } + + const createNewMatchModeConfig = () => { + openPanel(CreateNewMatchModeConfigPanel, undefined) + closePanel(panel!.id, CloseType.Overwrite) + } + return ( <>