Files
PriceUpdaterAppv2/src/tui/components/screens/ConfigurationScreen.jsx

994 lines
26 KiB
JavaScript

const React = require("react");
const { Box, Text, useInput, useApp } = require("ink");
const { useAppState } = require("../../providers/AppProvider.jsx");
const InputField = require("../common/InputField.jsx");
const TextInput = require("ink-text-input").default;
/**
* Configuration Screen Component
* Form-based interface for setting up Shopify credentials and operation parameters
* Requirements: 5.2, 6.1, 6.2, 6.3
*/
const ConfigurationScreen = () => {
const { appState, updateConfiguration, navigateBack, updateUIState } =
useAppState();
const { exit } = useApp();
// Enhanced form fields configuration with improved validation
const formFields = [
{
id: "shopDomain",
label: "Shopify Shop Domain",
placeholder: "your-store.myshopify.com",
description: "Your Shopify store domain (without https://)",
required: true,
validator: (value) => {
if (!value || value.trim() === "") {
return { isValid: false, message: "Domain is required" };
}
const trimmedValue = value.trim();
// Check for valid domain format
if (!trimmedValue.includes(".")) {
return { isValid: false, message: "Must be a valid domain" };
}
// Check for Shopify domain or custom domain
if (
!trimmedValue.includes(".myshopify.com") &&
!trimmedValue.match(
/^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.[a-zA-Z]{2,}$/
)
) {
return {
isValid: false,
message:
"Must be a valid Shopify domain (e.g., store.myshopify.com)",
};
}
// Check for protocol (should not include)
if (
trimmedValue.includes("http://") ||
trimmedValue.includes("https://")
) {
return {
isValid: false,
message: "Domain should not include http:// or https://",
};
}
return { isValid: true, message: "" };
},
},
{
id: "accessToken",
label: "Shopify Access Token",
placeholder: "shpat_your_access_token_here",
description: "Your Shopify Admin API access token",
secret: true,
required: true,
validator: (value) => {
if (!value || value.trim() === "") {
return { isValid: false, message: "Access token is required" };
}
const trimmedValue = value.trim();
if (trimmedValue.length < 10) {
return { isValid: false, message: "Token appears to be too short" };
}
// Basic format check for Shopify tokens
if (
!trimmedValue.startsWith("shpat_") &&
!trimmedValue.startsWith("shpca_") &&
!trimmedValue.startsWith("shppa_")
) {
return {
isValid: false,
message: "Token should start with shpat_, shpca_, or shppa_",
};
}
return { isValid: true, message: "" };
},
},
{
id: "targetTag",
label: "Target Product Tag",
placeholder: "sale",
description: "Products with this tag will be updated",
required: true,
validator: (value) => {
if (!value || value.trim() === "") {
return { isValid: false, message: "Target tag is required" };
}
const trimmedValue = value.trim();
// Check for valid tag format (no special characters except hyphens and underscores)
if (!/^[a-zA-Z0-9_-]+$/.test(trimmedValue)) {
return {
isValid: false,
message:
"Tag can only contain letters, numbers, hyphens, and underscores",
};
}
if (trimmedValue.length > 255) {
return {
isValid: false,
message: "Tag must be 255 characters or less",
};
}
return { isValid: true, message: "" };
},
},
{
id: "priceAdjustment",
label: "Price Adjustment Percentage",
placeholder: "10",
description:
"Percentage to adjust prices (positive for increase, negative for decrease)",
required: true,
validator: (value) => {
if (!value || value.trim() === "") {
return { isValid: false, message: "Percentage is required" };
}
const num = parseFloat(value);
if (isNaN(num)) {
return { isValid: false, message: "Must be a valid number" };
}
if (num < -100) {
return {
isValid: false,
message: "Cannot decrease prices by more than 100%",
};
}
if (num > 1000) {
return {
isValid: false,
message: "Price increase cannot exceed 1000%",
};
}
if (num === 0) {
return { isValid: false, message: "Percentage cannot be zero" };
}
return { isValid: true, message: "" };
},
},
{
id: "operationMode",
label: "Operation Mode",
placeholder: "update",
description:
"Choose between updating prices or rolling back to compare-at prices",
type: "select",
required: true,
options: [
{ value: "update", label: "Update Prices" },
{ value: "rollback", label: "Rollback Prices" },
],
validator: (value) => {
const validModes = ["update", "rollback"];
if (!validModes.includes(value)) {
return {
isValid: false,
message: "Must select a valid operation mode",
};
}
return { isValid: true, message: "" };
},
},
];
// Enhanced state management for form inputs
const [formValues, setFormValues] = React.useState(() => {
const initialValues = {};
formFields.forEach((field) => {
initialValues[field.id] = appState.configuration[field.id] || "";
});
return initialValues;
});
const [fieldValidation, setFieldValidation] = React.useState({});
const [focusedField, setFocusedField] = React.useState(0);
const [showValidation, setShowValidation] = React.useState(false);
const [hasInteracted, setHasInteracted] = React.useState({});
// Real-time field validation
const validateField = React.useCallback(
(fieldId, value) => {
const field = formFields.find((f) => f.id === fieldId);
if (!field || !field.validator) {
return { isValid: true, message: "" };
}
const result = field.validator(value);
return typeof result === "object"
? result
: { isValid: !result, message: result || "" };
},
[formFields]
);
// Validate all fields
const validateForm = React.useCallback(() => {
const newValidation = {};
let isFormValid = true;
formFields.forEach((field) => {
const validation = validateField(field.id, formValues[field.id]);
newValidation[field.id] = validation;
if (!validation.isValid) {
isFormValid = false;
}
});
setFieldValidation(newValidation);
return isFormValid;
}, [formFields, formValues, validateField]);
// Update validation when form values change
React.useEffect(() => {
if (showValidation) {
validateForm();
}
}, [formValues, showValidation, validateForm]);
// Validate loaded configuration completeness
const validateLoadedConfiguration = React.useCallback((config) => {
if (!config) return false;
// Check if all required fields have values
const requiredFields = [
"shopDomain",
"accessToken",
"targetTag",
"priceAdjustment",
"operationMode",
];
return requiredFields.every((field) => {
const value = config[field];
return value !== undefined && value !== null && value !== "";
});
}, []);
// Load configuration from environment file on component mount
React.useEffect(() => {
const loadedConfig = loadFromEnvironment();
if (loadedConfig) {
// Update form values with loaded configuration
setFormValues(loadedConfig);
// Validate the loaded configuration
const isConfigComplete = validateLoadedConfiguration(loadedConfig);
// Update app state with loaded configuration
updateConfiguration({
...loadedConfig,
isValid: isConfigComplete,
lastTested: appState.configuration.lastTested, // Preserve test status
});
// If configuration is complete, validate all fields
if (isConfigComplete) {
const allInteracted = {};
formFields.forEach((field) => {
allInteracted[field.id] = true;
});
setHasInteracted(allInteracted);
}
}
}, [
loadFromEnvironment,
updateConfiguration,
validateLoadedConfiguration,
formFields,
appState.configuration.lastTested,
]);
// Handle keyboard input
useInput((input, key) => {
if (key.escape) {
// Go back to main menu
navigateBack();
} else if (key.tab || (key.tab && key.shift)) {
// Navigate between fields and action buttons
const totalFocusableItems = formFields.length + 2; // fields + test button + save button
if (key.shift) {
// Shift+Tab - previous field
setFocusedField((prev) =>
prev > 0 ? prev - 1 : totalFocusableItems - 1
);
} else {
// Tab - next field
setFocusedField((prev) =>
prev < totalFocusableItems - 1 ? prev + 1 : 0
);
}
} else if (key.return || key.enter) {
// Handle Enter key
if (focusedField === formFields.length) {
// Test Connection button
handleTestConnection();
} else if (focusedField === formFields.length + 1) {
// Save button
handleSave();
} else {
// Move to next field
setFocusedField((prev) =>
prev < formFields.length + 1 ? prev + 1 : 0
);
}
} else if (key.upArrow) {
// Navigate up
const totalFocusableItems = formFields.length + 2;
setFocusedField((prev) =>
prev > 0 ? prev - 1 : totalFocusableItems - 1
);
} else if (key.downArrow) {
// Navigate down
const totalFocusableItems = formFields.length + 2;
setFocusedField((prev) =>
prev < totalFocusableItems - 1 ? prev + 1 : 0
);
}
});
// Enhanced input change handler with real-time validation
const handleInputChange = React.useCallback(
(fieldId, value) => {
setFormValues((prev) => ({
...prev,
[fieldId]: value,
}));
// Mark field as interacted
setHasInteracted((prev) => ({
...prev,
[fieldId]: true,
}));
// Perform real-time validation for interacted fields
if (hasInteracted[fieldId] || showValidation) {
const validation = validateField(fieldId, value);
setFieldValidation((prev) => ({
...prev,
[fieldId]: validation,
}));
}
},
[hasInteracted, showValidation, validateField]
);
// Enhanced save handler with comprehensive validation
const handleSave = React.useCallback(() => {
setShowValidation(true);
// Mark all fields as interacted
const allInteracted = {};
formFields.forEach((field) => {
allInteracted[field.id] = true;
});
setHasInteracted(allInteracted);
if (validateForm()) {
try {
// Convert and validate price adjustment
const priceAdjustment = parseFloat(formValues.priceAdjustment);
const config = {
shopDomain: formValues.shopDomain.trim(),
accessToken: formValues.accessToken.trim(),
targetTag: formValues.targetTag.trim(),
priceAdjustment,
operationMode: formValues.operationMode,
isValid: true,
lastTested: appState.configuration.lastTested, // Preserve last test time
};
// Update application state
updateConfiguration(config);
// Save to environment file
saveToEnvironment(config);
// Navigate back to previous screen
navigateBack();
} catch (error) {
console.error("Error saving configuration:", error);
// Could add error state here for user feedback
}
}
}, [
formValues,
formFields,
validateForm,
updateConfiguration,
navigateBack,
appState.configuration.lastTested,
]);
// Load configuration from environment file
const loadFromEnvironment = React.useCallback(() => {
try {
const fs = require("fs");
const path = require("path");
const envPath = path.resolve(process.cwd(), ".env");
// Check if .env file exists
if (!fs.existsSync(envPath)) {
return null; // No existing configuration
}
// Read and parse .env file
const envContent = fs.readFileSync(envPath, "utf8");
const envVars = {};
// Parse environment variables
envContent.split("\n").forEach((line) => {
const trimmedLine = line.trim();
if (trimmedLine && !trimmedLine.startsWith("#")) {
const [key, ...valueParts] = trimmedLine.split("=");
if (key && valueParts.length > 0) {
envVars[key.trim()] = valueParts.join("=").trim();
}
}
});
// Map environment variables to configuration
const config = {
shopDomain: envVars.SHOPIFY_SHOP_DOMAIN || "",
accessToken: envVars.SHOPIFY_ACCESS_TOKEN || "",
targetTag: envVars.TARGET_TAG || "",
priceAdjustment: parseFloat(envVars.PRICE_ADJUSTMENT_PERCENTAGE) || 0,
operationMode: envVars.OPERATION_MODE || "update",
};
// Validate loaded configuration
const hasValidData =
config.shopDomain || config.accessToken || config.targetTag;
return hasValidData ? config : null;
} catch (error) {
console.error("Failed to load configuration from environment:", error);
return null;
}
}, []);
// Enhanced configuration persistence with better error handling and validation
const saveToEnvironment = React.useCallback(
(config) => {
try {
const fs = require("fs");
const path = require("path");
const envPath = path.resolve(process.cwd(), ".env");
// Read existing .env file or create empty content
let envContent = "";
try {
envContent = fs.readFileSync(envPath, "utf8");
} catch (err) {
// If file doesn't exist, start with empty content
envContent = "";
}
// Prepare environment variables mapping
const envVars = {
SHOPIFY_SHOP_DOMAIN: config.shopDomain,
SHOPIFY_ACCESS_TOKEN: config.accessToken,
TARGET_TAG: config.targetTag,
PRICE_ADJUSTMENT_PERCENTAGE: config.priceAdjustment.toString(),
OPERATION_MODE: config.operationMode,
};
// Process each environment variable
for (const [key, value] of Object.entries(envVars)) {
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`^${escapedKey}=.*$`, "m");
const line = `${key}=${value}`;
if (envContent.match(regex)) {
// Update existing variable
envContent = envContent.replace(regex, line);
} else {
// Add new variable
if (envContent && !envContent.endsWith("\n")) {
envContent += "\n";
}
envContent += `${line}\n`;
}
}
// Write updated content to .env file
fs.writeFileSync(envPath, envContent, "utf8");
// Update UI state to show success
updateUIState({
lastSaveStatus: "success",
lastSaveTime: new Date(),
});
} catch (error) {
console.error("Failed to save configuration to environment:", error);
// Update UI state to show error
updateUIState({
lastSaveStatus: "error",
lastSaveError: error.message,
lastSaveTime: new Date(),
});
throw error; // Re-throw to handle in calling function
}
},
[updateUIState]
);
// Enhanced API connection testing with real Shopify service integration
const handleTestConnection = React.useCallback(async () => {
// Validate required fields first
const requiredFields = ["shopDomain", "accessToken"];
const tempValidation = {};
let hasError = false;
requiredFields.forEach((fieldId) => {
const field = formFields.find((f) => f.id === fieldId);
if (field && field.validator) {
const validation = field.validator(formValues[fieldId]);
const result =
typeof validation === "object"
? validation
: { isValid: !validation, message: validation || "" };
tempValidation[fieldId] = result;
if (!result.isValid) {
hasError = true;
}
}
});
if (hasError) {
setFieldValidation((prev) => ({ ...prev, ...tempValidation }));
setShowValidation(true);
// Update UI state to show validation error
updateUIState({
lastTestStatus: "validation_error",
lastTestError: "Please fix validation errors before testing connection",
lastTestTime: new Date(),
});
return;
}
try {
// Update UI state to show testing in progress
updateUIState({
lastTestStatus: "testing",
lastTestError: null,
lastTestTime: new Date(),
});
// Create a temporary configuration for testing
const testConfig = {
shopDomain: formValues.shopDomain.trim(),
accessToken: formValues.accessToken.trim(),
targetTag: formValues.targetTag.trim(),
priceAdjustment: parseFloat(formValues.priceAdjustment),
operationMode: formValues.operationMode,
};
// Test the connection using ShopifyService
const ShopifyService = require("../../../services/shopify");
// Create a temporary service instance with test configuration
const originalEnv = process.env;
process.env = {
...originalEnv,
SHOPIFY_SHOP_DOMAIN: testConfig.shopDomain,
SHOPIFY_ACCESS_TOKEN: testConfig.accessToken,
TARGET_TAG: testConfig.targetTag,
PRICE_ADJUSTMENT_PERCENTAGE: testConfig.priceAdjustment.toString(),
OPERATION_MODE: testConfig.operationMode,
};
const shopifyService = new ShopifyService();
const testResult = await shopifyService.testConnection();
// Restore original environment
process.env = originalEnv;
if (testResult) {
// Connection successful
updateConfiguration({
...testConfig,
isValid: true,
lastTested: new Date(),
});
updateUIState({
lastTestStatus: "success",
lastTestError: null,
lastTestTime: new Date(),
});
} else {
// Connection failed
updateConfiguration({
...testConfig,
isValid: false,
lastTested: new Date(),
});
updateUIState({
lastTestStatus: "failed",
lastTestError:
"Failed to connect to Shopify API. Please check your credentials.",
lastTestTime: new Date(),
});
}
} catch (error) {
console.error("Connection test error:", error);
// Update configuration with test failure
updateConfiguration({
...formValues,
priceAdjustment: parseFloat(formValues.priceAdjustment),
isValid: false,
lastTested: new Date(),
});
updateUIState({
lastTestStatus: "error",
lastTestError:
error.message ||
"An unexpected error occurred during connection test",
lastTestTime: new Date(),
});
}
}, [formFields, formValues, updateConfiguration, updateUIState]);
return React.createElement(
Box,
{ flexDirection: "column", padding: 1 },
// Header
React.createElement(
Box,
{ flexDirection: "column", marginBottom: 1 },
React.createElement(
Text,
{ bold: true, color: "cyan" },
"⚙️ Configuration"
),
React.createElement(
Text,
{ color: "gray", fontSize: "small" },
"Set up your Shopify credentials and operation parameters"
)
),
// Enhanced form fields with improved validation display
React.createElement(
Box,
{ flexDirection: "column", marginBottom: 2 },
formFields.map((field, index) => {
const isFocused = focusedField === index;
const validation = fieldValidation[field.id] || {
isValid: true,
message: "",
};
const hasError =
!validation.isValid && (hasInteracted[field.id] || showValidation);
const currentValue = formValues[field.id] || "";
return React.createElement(
Box,
{
key: field.id,
borderStyle: "single",
borderColor: hasError ? "red" : isFocused ? "blue" : "gray",
paddingX: 1,
paddingY: 1,
marginBottom: 1,
flexDirection: "column",
},
// Field label and description
React.createElement(
Box,
{ flexDirection: "row", alignItems: "center", marginBottom: 1 },
React.createElement(
Text,
{
bold: true,
color: hasError ? "red" : isFocused ? "blue" : "white",
},
`${field.label}${field.required ? "*" : ""}:`
),
React.createElement(
Box,
{ flexGrow: 1 },
React.createElement(
Text,
{ color: "gray" },
` ${field.description}`
)
)
),
// Input field
React.createElement(
Box,
{ flexDirection: "row" },
field.type === "select"
? React.createElement(
Box,
{ flexDirection: "column" },
field.options.map((option, optIndex) =>
React.createElement(
Box,
{
key: optIndex,
borderStyle:
formValues[field.id] === option.value
? "single"
: "none",
borderColor:
isFocused && formValues[field.id] === option.value
? "blue"
: "gray",
paddingX: 2,
paddingY: 0.5,
marginBottom: 0.5,
backgroundColor:
formValues[field.id] === option.value
? isFocused
? "blue"
: "gray"
: undefined,
},
React.createElement(
Text,
{
color:
formValues[field.id] === option.value
? "white"
: "gray",
},
option.label
)
)
)
)
: React.createElement(InputField, {
value: currentValue,
placeholder: field.placeholder,
onChange: (value) => handleInputChange(field.id, value),
validation: field.validator,
showError: hasInteracted[field.id] || showValidation,
mask: field.secret ? "*" : undefined,
focus: isFocused,
required: field.required,
})
),
// Error message for select fields (InputField handles its own errors)
hasError &&
field.type === "select" &&
React.createElement(
Text,
{ color: "red", italic: true },
`${validation.message}`
),
// Success indicator for valid fields
!hasError &&
hasInteracted[field.id] &&
currentValue &&
React.createElement(
Text,
{ color: "green", italic: true },
` ✓ Valid`
)
);
})
),
// Action buttons
React.createElement(
Box,
{ flexDirection: "row", justifyContent: "space-between" },
React.createElement(
Box,
{ flexDirection: "column", width: "48%" },
React.createElement(
Box,
{
borderStyle: "single",
borderColor:
appState.uiState?.lastTestStatus === "testing"
? "yellow"
: "gray",
paddingX: 2,
paddingY: 1,
alignItems: "center",
backgroundColor:
focusedField === formFields.length ? "yellow" : undefined,
},
React.createElement(
Text,
{
color: focusedField === formFields.length ? "black" : "white",
bold: true,
},
appState.uiState?.lastTestStatus === "testing"
? "Testing..."
: "Test Connection"
)
),
React.createElement(
Text,
{ color: "gray", italic: true, marginTop: 0.5 },
"Verify your credentials"
)
),
React.createElement(
Box,
{ flexDirection: "column", width: "48%" },
React.createElement(
Box,
{
borderStyle: "single",
borderColor: "green",
paddingX: 2,
paddingY: 1,
alignItems: "center",
backgroundColor:
focusedField === formFields.length + 1 ? "green" : undefined,
},
React.createElement(
Text,
{
color: "white",
bold: true,
},
"Save & Exit"
)
),
React.createElement(
Text,
{ color: "gray", italic: true, marginTop: 0.5 },
"Save configuration and return to menu"
)
)
),
// Enhanced configuration status with save information
React.createElement(
Box,
{
flexDirection: "column",
marginTop: 2,
borderTopStyle: "single",
borderColor: "gray",
paddingTop: 2,
},
React.createElement(
Text,
{
color: appState.configuration.isValid ? "green" : "red",
bold: true,
},
`Configuration Status: ${
appState.configuration.isValid ? "✓ Valid" : "⚠ Incomplete"
}`
),
appState.configuration.lastTested &&
React.createElement(
Text,
{ color: "gray", fontSize: "small" },
`Last tested: ${appState.configuration.lastTested.toLocaleString()}`
),
// Connection test status display
appState.uiState?.lastTestStatus &&
React.createElement(
Text,
{
color:
appState.uiState.lastTestStatus === "success"
? "green"
: appState.uiState.lastTestStatus === "testing"
? "yellow"
: "red",
fontSize: "small",
},
appState.uiState.lastTestStatus === "success"
? `✓ Connection test successful at ${appState.uiState.lastTestTime?.toLocaleTimeString()}`
: appState.uiState.lastTestStatus === "testing"
? "⏳ Testing connection..."
: `⚠ Connection test failed: ${
appState.uiState.lastTestError || "Unknown error"
}`
),
// Save status display
appState.uiState?.lastSaveStatus &&
React.createElement(
Text,
{
color:
appState.uiState.lastSaveStatus === "success" ? "green" : "red",
fontSize: "small",
},
appState.uiState.lastSaveStatus === "success"
? `✓ Saved to .env file at ${appState.uiState.lastSaveTime?.toLocaleTimeString()}`
: `⚠ Save failed: ${
appState.uiState.lastSaveError || "Unknown error"
}`
),
// Configuration file status
React.createElement(
Text,
{ color: "gray", fontSize: "small" },
"Configuration will be saved to .env file in project root"
)
),
// Instructions
React.createElement(
Box,
{ flexDirection: "column", marginTop: 2 },
React.createElement(Text, { color: "gray" }, "Navigation:"),
React.createElement(Text, { color: "gray" }, " ↑/↓ - Navigate fields"),
React.createElement(
Text,
{ color: "gray" },
" Tab/Shift+Tab - Next/Previous field"
),
React.createElement(Text, { color: "gray" }, " Enter - Save/Next field"),
React.createElement(Text, { color: "gray" }, " Esc - Back to menu")
),
// Enhanced validation summary
showValidation &&
React.createElement(
Box,
{
flexDirection: "column",
marginTop: 2,
padding: 1,
borderStyle: "single",
borderColor: Object.values(fieldValidation).some((v) => !v.isValid)
? "red"
: "green",
},
React.createElement(
Text,
{
color: Object.values(fieldValidation).some((v) => !v.isValid)
? "red"
: "green",
bold: true,
},
Object.values(fieldValidation).some((v) => !v.isValid)
? "⚠ Validation Errors:"
: "✓ All fields are valid"
),
Object.values(fieldValidation).some((v) => !v.isValid) &&
React.createElement(
Text,
{ color: "red" },
"Please fix the errors above before saving."
),
// Show count of valid/invalid fields
React.createElement(
Text,
{ color: "gray", fontSize: "small" },
`${Object.values(fieldValidation).filter((v) => v.isValid).length}/${
formFields.length
} fields valid`
)
)
);
};
module.exports = ConfigurationScreen;