Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions configure/src/components/SaveBar/SaveBar.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ const useStyles = makeStyles((theme) => ({
cursor: "not-allowed !important",
background: `${theme.palette.swatches.red[500]} !important`,
},
errorIndicator: {
width: "10px",
height: "10px",
borderRadius: "50%",
backgroundColor: theme.palette.error.main,
marginLeft: "6px",
display: "inline-block",
},
}));

export default function SaveBar() {
Expand All @@ -60,6 +68,8 @@ export default function SaveBar() {

const mission = useSelector((state) => state.core.mission);
const lockConfig = useSelector((state) => state.core.lockConfig);
const validationErrors = useSelector((state) => state.core.validationErrors);
const hasValidationErrors = validationErrors && validationErrors.length > 0;

return (
<>
Expand All @@ -77,6 +87,7 @@ export default function SaveBar() {
<Button
className={clsx(c.save, { [c.saveDisabled]: lockConfig })}
variant="contained"
startIcon={hasValidationErrors ? <span className={c.errorIndicator} /> : null}
endIcon={<SaveIcon />}
onClick={() => {
dispatch(
Expand Down
8 changes: 8 additions & 0 deletions configure/src/core/ConfigureStore.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createSlice } from "@reduxjs/toolkit";

import { calls } from "./calls";
import { getAllValidationErrors } from "./validators";

window.newUUIDCount = 0;
window.configId = parseInt(Math.random() * 100000);
Expand All @@ -17,6 +18,7 @@ export const ConfigureStore = createSlice({
stacCollections: [],
userEntries: [],
page: null,
validationErrors: [],
modal: {
newMission: false,
layer: false,
Expand Down Expand Up @@ -64,6 +66,8 @@ export const ConfigureStore = createSlice({
},
setConfiguration: (state, action) => {
state.configuration = action.payload;
// Update validation errors whenever configuration changes
state.validationErrors = getAllValidationErrors(action.payload);
},
setToolConfiguration: (state, action) => {
state.toolConfiguration = action.payload;
Expand Down Expand Up @@ -106,6 +110,9 @@ export const ConfigureStore = createSlice({
severity: action.payload.severity,
};
},
setValidationErrors: (state, action) => {
state.validationErrors = action.payload;
},
clearLockConfig: (state, action) => {
state.lockConfigTypes[action.payload.type || "main"] = false;

Expand Down Expand Up @@ -210,6 +217,7 @@ export const {
setPage,
setModal,
setSnackBarText,
setValidationErrors,
saveConfiguration,
clearLockConfig,
setLockConfig,
Expand Down
35 changes: 32 additions & 3 deletions configure/src/core/Maker.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import FormControl from "@mui/material/FormControl";
import Select from "@mui/material/Select";
import IconButton from "@mui/material/IconButton";
import Slider from "@mui/material/Slider";
import FormHelperText from "@mui/material/FormHelperText";

import AddIcon from "@mui/icons-material/Add";
import CloseIcon from "@mui/icons-material/Close";
Expand All @@ -39,6 +40,7 @@ import {
getLayerByUUID,
isUrlAbsolute,
} from "./utils";
import { isFieldRequired } from "./validators";

import Map from "../components/Map/Map";
import ColorButton from "../components/ColorButton/ColorButton";
Expand Down Expand Up @@ -237,6 +239,9 @@ const useStyles = makeStyles((theme) => ({
marginBottom: "-13px !important",
fontSize: "14px !important",
},
noMarginHelperText: {
marginLeft: "0px !important",
},
}));

const getComponent = (
Expand Down Expand Up @@ -269,6 +274,10 @@ const getComponent = (
}
disabled = !switchVal;
}
const isRequired = isFieldRequired(com, layer, configuration);
const fieldValue = value != null ? value : getIn(directConf, com.field, "");
const hasError = isRequired && (fieldValue === "" || fieldValue == null);

switch (com.type) {
case "gap":
return (
Expand All @@ -286,10 +295,16 @@ const getComponent = (
variant="filled"
size="small"
disabled={disabled}
required={isRequired}
error={hasError}
helperText={hasError ? "This field is required" : ""}
FormHelperTextProps={{
className: c.noMarginHelperText
}}
inputProps={{
autoComplete: "off",
}}
value={value != null ? value : getIn(directConf, com.field, "")}
value={fieldValue}
onChange={(e) => {
updateConfiguration(forceField || com.field, e.target.value, layer);
}}
Expand Down Expand Up @@ -326,10 +341,16 @@ const getComponent = (
variant="filled"
size="small"
disabled={disabled}
required={isRequired}
error={hasError}
helperText={hasError ? "This field is required" : ""}
FormHelperTextProps={{
className: c.noMarginHelperText
}}
inputProps={{
autoComplete: "off",
}}
value={value != null ? value : getIn(directConf, com.field, "")}
value={fieldValue}
onChange={(e) => {
updateConfiguration(forceField || com.field, e.target.value, layer);
}}
Expand Down Expand Up @@ -599,17 +620,25 @@ const getComponent = (
</div>
);
case "number":
const numberValue = value != null ? value : getIn(directConf, com.field, "");
const numberHasError = isRequired && (numberValue === "" || numberValue == null || isNaN(numberValue));
inner = (
<TextField
className={c.text}
label={com.name}
variant="filled"
size="small"
disabled={disabled}
required={isRequired}
error={numberHasError}
helperText={numberHasError ? "This field is required" : ""}
FormHelperTextProps={{
className: c.noMarginHelperText
}}
inputProps={{
autoComplete: "off",
}}
value={value != null ? value : getIn(directConf, com.field, "")}
value={numberValue}
onChange={(e) => {
let v = e.target.value;
if (v != "") {
Expand Down
216 changes: 216 additions & 0 deletions configure/src/core/validators.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// Validation functions that mirror backend validation rules

export const validateConfiguration = (configuration) => {
const errors = [];

if (!configuration) {
errors.push({ field: null, message: "Configuration is missing" });
return errors;
}

// Check top-level required fields
if (!configuration.msv) {
errors.push({ field: "msv", message: "Mission-Site-View (msv) is required" });
} else {
// Check msv.view array
if (!configuration.msv.view || !Array.isArray(configuration.msv.view)) {
errors.push({ field: "msv.view", message: "Initial view is required" });
} else {
if (configuration.msv.view[0] == null || configuration.msv.view[0] === "") {
errors.push({ field: "msv.view.0", message: "Initial Latitude is required" });
}
if (configuration.msv.view[1] == null || configuration.msv.view[1] === "") {
errors.push({ field: "msv.view.1", message: "Initial Longitude is required" });
}
if (configuration.msv.view[2] == null || configuration.msv.view[2] === "") {
errors.push({ field: "msv.view.2", message: "Initial Zoom Level is required" });
}
}
}

if (!configuration.layers) {
errors.push({ field: "layers", message: "Layers array is required" });
}

if (!configuration.tools) {
errors.push({ field: "tools", message: "Tools object is required" });
}

return errors;
};

export const validateLayer = (layer) => {
const errors = [];

if (!layer) {
errors.push({ field: null, message: "Layer is missing" });
return errors;
}

// Check layer name
if (layer.name == null || layer.name === "" || layer.name === "undefined") {
errors.push({ field: "name", message: "Layer name is required" });
}

// Check by layer type
switch (layer.type) {
case "tile":
case "image":
if (!layer.url || layer.url === "" || layer.url === "undefined") {
errors.push({ field: "url", message: "URL is required" });
}
if (layer.minZoom == null || isNaN(layer.minZoom)) {
errors.push({ field: "minZoom", message: "Minimum Zoom is required" });
} else if (layer.minZoom < 0) {
errors.push({ field: "minZoom", message: "Minimum Zoom must be >= 0" });
}
if (layer.maxNativeZoom == null || isNaN(layer.maxNativeZoom)) {
errors.push({ field: "maxNativeZoom", message: "Maximum Native Zoom is required" });
}
if (layer.maxZoom == null || isNaN(layer.maxZoom)) {
errors.push({ field: "maxZoom", message: "Maximum Zoom is required" });
}
if (!isNaN(layer.minZoom) && !isNaN(layer.maxNativeZoom) && layer.minZoom > layer.maxNativeZoom) {
errors.push({ field: "minZoom", message: "Minimum Zoom cannot be greater than Maximum Native Zoom" });
}
break;

case "vectortile":
if (!layer.url || layer.url === "" || layer.url === "undefined") {
errors.push({ field: "url", message: "URL is required" });
}
if (layer.minZoom == null || isNaN(layer.minZoom)) {
errors.push({ field: "minZoom", message: "Minimum Zoom is required" });
}
if (layer.maxNativeZoom == null || isNaN(layer.maxNativeZoom)) {
errors.push({ field: "maxNativeZoom", message: "Maximum Native Zoom is required" });
}
if (layer.maxZoom == null || isNaN(layer.maxZoom)) {
errors.push({ field: "maxZoom", message: "Maximum Zoom is required" });
}
break;

case "data":
if (!layer.demtileurl || layer.demtileurl === "" || layer.demtileurl === "undefined") {
errors.push({ field: "demtileurl", message: "DEM Tile URL is required" });
}
if (layer.minZoom == null || isNaN(layer.minZoom)) {
errors.push({ field: "minZoom", message: "Minimum Zoom is required" });
}
if (layer.maxNativeZoom == null || isNaN(layer.maxNativeZoom)) {
errors.push({ field: "maxNativeZoom", message: "Maximum Native Zoom is required" });
}
if (layer.maxZoom == null || isNaN(layer.maxZoom)) {
errors.push({ field: "maxZoom", message: "Maximum Zoom is required" });
}
break;

case "query":
if (!layer.query?.endpoint || layer.query.endpoint === "" || layer.query.endpoint === "undefined") {
errors.push({ field: "query.endpoint", message: "Query Endpoint is required" });
}
break;

case "vector":
case "velocity":
if (layer.controlled !== true) {
if (!layer.url || layer.url === "" || layer.url === "undefined") {
errors.push({ field: "url", message: "URL is required (unless layer is controlled)" });
}
}
break;

case "model":
if (!layer.url || layer.url === "" || layer.url === "undefined") {
errors.push({ field: "url", message: "URL is required" });
}
if (layer.position?.longitude == null || isNaN(layer.position?.longitude)) {
errors.push({ field: "position.longitude", message: "Longitude is required" });
}
if (layer.position?.latitude == null || isNaN(layer.position?.latitude)) {
errors.push({ field: "position.latitude", message: "Latitude is required" });
}
if (layer.position?.elevation == null || isNaN(layer.position?.elevation)) {
errors.push({ field: "position.elevation", message: "Elevation is required" });
}
if (layer.rotation?.x == null || isNaN(layer.rotation?.x)) {
errors.push({ field: "rotation.x", message: "Rotation X is required" });
}
if (layer.rotation?.y == null || isNaN(layer.rotation?.y)) {
errors.push({ field: "rotation.y", message: "Rotation Y is required" });
}
if (layer.rotation?.z == null || isNaN(layer.rotation?.z)) {
errors.push({ field: "rotation.z", message: "Rotation Z is required" });
}
if (layer.scale == null || isNaN(layer.scale)) {
errors.push({ field: "scale", message: "Scale is required" });
}
break;

case "header":
// No additional required fields for header
break;

default:
if (layer.type) {
errors.push({ field: "type", message: `Unknown layer type: ${layer.type}` });
}
}

return errors;
};

// Check if a field should be required based on conditional logic
export const isFieldRequired = (component, layer, configuration) => {
if (!component.required) return false;

// Check conditional requirements
if (component.conditionalRequired) {
const condition = component.conditionalRequired;
const fieldValue = layer?.[condition.field];

// If the condition field equals the condition value, field is NOT required
if (fieldValue === condition.value) {
return false;
}
}

return true;
};

// Get all validation errors for current configuration
export const getAllValidationErrors = (configuration) => {
const errors = [];

// Validate top-level configuration
const configErrors = validateConfiguration(configuration);
errors.push(...configErrors);

// Validate each layer
if (configuration?.layers && Array.isArray(configuration.layers)) {
const traverseLayers = (layers, path = []) => {
layers.forEach((layer, index) => {
const layerPath = [...path, index];
const layerErrors = validateLayer(layer);

// Add layer path to errors
layerErrors.forEach(error => {
errors.push({
...error,
layerPath,
layerName: layer.name || `Layer ${layerPath.join('.')}`
});
});

// Traverse sublayers
if (layer.sublayers && Array.isArray(layer.sublayers)) {
traverseLayers(layer.sublayers, layerPath);
}
});
};

traverseLayers(configuration.layers);
}

return errors;
};
Loading