Skip to content
Open
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
3 changes: 3 additions & 0 deletions console/common/config/rush/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com).
*
* WSO2 LLC. licenses this file to you under the Apache License,
* Version 2.0 (the "License"); you may not use this file except
* in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { useState, useRef, useCallback, useMemo, ChangeEvent } from "react";
import {
Box,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Typography,
useTheme,
} from "@wso2/oxygen-ui";
import { FileText, Upload } from "@wso2/oxygen-ui-icons-react";
import { parseEnvContent, EnvVariable } from "../utils";

interface EnvBulkImportModalProps {
open: boolean;
onClose: () => void;
onImport: (envVars: EnvVariable[]) => void;
}

export function EnvBulkImportModal({
open,
onClose,
onImport,
}: EnvBulkImportModalProps) {
const theme = useTheme();
const [content, setContent] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);

// Parse content and get variables count
const parsedVars = useMemo(() => parseEnvContent(content), [content]);
const variablesCount = parsedVars.length;

// Handle textarea change
const handleContentChange = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value);
},
[]
);

// Handle file upload
const handleFileUpload = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;

const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result;
if (typeof text === "string") {
setContent(text);
}
};
reader.readAsText(file);

// Reset input so same file can be selected again
e.target.value = "";
},
[]
);

// Trigger file input click
const handleUploadClick = useCallback(() => {
fileInputRef.current?.click();
}, []);

// Handle import button click
const handleImport = useCallback(() => {
if (variablesCount > 0) {
onImport(parsedVars);
setContent("");
onClose();
}
}, [variablesCount, parsedVars, onImport, onClose]);

// Handle cancel/close
const handleClose = useCallback(() => {
setContent("");
onClose();
}, [onClose]);

return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="sm"
fullWidth
>
<DialogTitle>
<Box display="flex" alignItems="center" gap={1}>
<FileText size={20} />
<Typography variant="h6">
Bulk Import Environment Variables
</Typography>
</Box>
</DialogTitle>

<DialogContent>
<Box display="flex" flexDirection="column" gap={2}>
<Typography variant="body2" color="text.secondary">
Paste your .env content below or upload a file.
</Typography>

{/* Textarea for pasting .env content */}
<Box
component="textarea"
value={content}
onChange={handleContentChange}
placeholder={`# Example format:\nAPI_KEY=your_api_key\nDATABASE_URL=postgres://...\nDEBUG="true"`}
sx={{
width: "100%",
minHeight: 200,
padding: 1.5,
fontFamily: "monospace",
fontSize: 13,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 1,
resize: "vertical",
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary,
"&:focus": {
outline: "none",
borderColor: theme.palette.primary.main,
},
}}
/>

{/* File upload button */}
<Box>
<input
ref={fileInputRef}
type="file"
accept=".env,.txt,text/plain"
onChange={handleFileUpload}
style={{ display: "none" }}
/>
<Button
variant="outlined"
size="small"
startIcon={<Upload size={16} />}
onClick={handleUploadClick}
>
Upload .env File
</Button>
</Box>

{/* Variables count indicator */}
<Typography
variant="body2"
color={variablesCount > 0 ? "success.main" : "text.secondary"}
>
{variablesCount > 0
? `${variablesCount} variable${variablesCount !== 1 ? "s" : ""} detected`
: "No variables detected"}
</Typography>
</Box>
</DialogContent>

<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button
variant="contained"
onClick={handleImport}
disabled={variablesCount === 0}
>
Import
</Button>
</DialogActions>
</Dialog>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,53 @@
* under the License.
*/

import { useState, useCallback, useMemo } from "react";
import { Box, Button, Typography } from "@wso2/oxygen-ui";
import { Plus as Add } from "@wso2/oxygen-ui-icons-react";
import { Plus as Add, FileText } from "@wso2/oxygen-ui-icons-react";
import { useFieldArray, useFormContext, useWatch } from "react-hook-form";
import { EnvVariableEditor } from "@agent-management-platform/views";
import { EnvBulkImportModal } from "./EnvBulkImportModal";
import type { EnvVariable } from "../utils";

export const EnvironmentVariable = () => {
const { control, formState: { errors }, register } = useFormContext();
const { fields, append, remove } = useFieldArray({ control, name: 'env' });
const envValues = useWatch({ control, name: 'env' }) || [];
const { control, formState: { errors }, register, getValues } = useFormContext();
const { fields, append, remove, replace } = useFieldArray({ control, name: 'env' });
const watchedEnvValues = useWatch({ control, name: 'env' });
const [importModalOpen, setImportModalOpen] = useState(false);

const isOneEmpty = envValues.some((e: any) => !e?.key || !e?.value);
// Memoize envValues to stabilize dependency for useCallback
const envValues = useMemo(
() => (watchedEnvValues || []) as EnvVariable[],
[watchedEnvValues]
);

const isOneEmpty = envValues.some((e) => !e?.key || !e?.value);

// Handle bulk import - merge imported vars with existing ones, remove empty rows
const handleImport = useCallback((importedVars: EnvVariable[]) => {
// Get current values directly from form to avoid stale closure
const currentEnv = (getValues('env') || []) as EnvVariable[];

// Filter out empty rows from existing values
const nonEmptyExisting = currentEnv.filter((env) => env?.key && env?.value);

// Map existing keys to their values for merging
const existingMap = new Map<string, string>();
nonEmptyExisting.forEach((env) => {
existingMap.set(env.key, env.value);
});

// Merge: imported vars override existing ones with same key
importedVars.forEach((imported) => {
existingMap.set(imported.key, imported.value);
});

// Convert map back to array
const mergedEnv = Array.from(existingMap.entries()).map(([key, value]) => ({ key, value }));

// Replace all fields with merged result
replace(mergedEnv);
}, [getValues, replace]);

return (
<Box display="flex" flexDirection="column" gap={2} width="100%">
Expand All @@ -37,7 +73,7 @@ export const EnvironmentVariable = () => {
Set environment variables for your agent deployment.
</Typography>
<Box display="flex" flexDirection="column" gap={2}>
{fields.map((field: any, index: number) => (
{fields.map((field, index) => (
<EnvVariableEditor
key={field.id}
fieldName="env"
Expand All @@ -49,7 +85,7 @@ export const EnvironmentVariable = () => {
/>
))}
</Box>
<Box display="flex" justifyContent="flex-start" width="100%">
<Box display="flex" justifyContent="flex-start" gap={1} width="100%">
<Button
startIcon={<Add fontSize="small" />}
disabled={isOneEmpty}
Expand All @@ -59,7 +95,21 @@ export const EnvironmentVariable = () => {
>
Add
</Button>
<Button
startIcon={<FileText fontSize="small" />}
variant="outlined"
color="primary"
onClick={() => setImportModalOpen(true)}
>
Bulk Import
</Button>
</Box>

<EnvBulkImportModal
open={importModalOpen}
onClose={() => setImportModalOpen(false)}
onImport={handleImport}
/>
</Box>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ export * from './EnvironmentVariable';
export * from './AgentLayout';
export * from './EnvironmentCard';
export * from './ConfirmationDialog';
export * from './EnvBulkImportModal';
1 change: 1 addition & 0 deletions console/workspaces/libs/shared-component/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@
*/

export * from './components';
export * from './utils';
70 changes: 70 additions & 0 deletions console/workspaces/libs/shared-component/src/utils/envParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com).
*
* WSO2 LLC. licenses this file to you under the Apache License,
* Version 2.0 (the "License"); you may not use this file except
* in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

export interface EnvVariable {
key: string;
value: string;
}

// Strips surrounding quotes from a value (single or double quotes)
function stripQuotes(value: string): string {
const trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}

// Parses .env file content into an array of key-value pairs
export function parseEnvContent(content: string): EnvVariable[] {
const lines = content.split(/\r?\n/);
const envMap = new Map<string, string>();

for (const line of lines) {
const trimmedLine = line.trim();

// Skip empty lines and comments
if (!trimmedLine || trimmedLine.startsWith('#')) {
continue;
}

// Find the first '=' to split key and value
const equalIndex = trimmedLine.indexOf('=');
if (equalIndex === -1) {
continue; // Skip lines without '='
}

const key = trimmedLine.substring(0, equalIndex).trim();
const rawValue = trimmedLine.substring(equalIndex + 1);
const value = stripQuotes(rawValue);

// Skip entries with empty keys
if (!key) {
continue;
}

// Use Map to handle duplicates (last value wins)
envMap.set(key, value);
}

// Convert Map to array
return Array.from(envMap.entries()).map(([key, value]) => ({ key, value }));
}
Loading