Skip to content

Commit 1c5b1b4

Browse files
skoevaExar04
andcommitted
frontend: EditorDialog: Add file upload options
Co-authored-by: Evangelos Skopelitis <eskopelitis@microsoft.com> Co-authored-by: Exar04 <yashdhadwe@gmail.com>
1 parent 491dbe3 commit 1c5b1b4

17 files changed

+507
-0
lines changed

frontend/src/components/common/Resource/EditorDialog.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import Loader from '../Loader';
5050
import Tabs from '../Tabs';
5151
import DocsViewer from './DocsViewer';
5252
import SimpleEditor from './SimpleEditor';
53+
import { UploadDialog } from './UploadDialog';
5354

5455
type KubeObjectIsh = Partial<KubeObjectInterface>;
5556

@@ -123,6 +124,7 @@ export default function EditorDialog(props: EditorDialogProps) {
123124
'useSimpleEditor',
124125
false
125126
);
127+
const [uploadFiles, setUploadFiles] = React.useState(false);
126128

127129
const dispatchCreateEvent = useEventCallback(HeadlampEventType.CREATE_RESOURCE);
128130
const dispatch: AppDispatch = useDispatch();
@@ -416,6 +418,7 @@ export default function EditorDialog(props: EditorDialogProps) {
416418
<Loader title={t('Loading editor')} />
417419
) : (
418420
<React.Fragment>
421+
{uploadFiles ? <UploadDialog setUploadFiles={setUploadFiles} setCode={setCode} /> : ''}
419422
<DialogContent
420423
sx={{
421424
height: '80%',
@@ -461,6 +464,14 @@ export default function EditorDialog(props: EditorDialogProps) {
461464
}
462465
label={t('Use minimal editor')}
463466
/>
467+
<Button
468+
variant="contained"
469+
onClick={() => {
470+
setUploadFiles(true);
471+
}}
472+
>
473+
{t('translation|Upload File/URL')}
474+
</Button>
464475
</FormGroup>
465476
</Grid>
466477
</Grid>
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
/*
2+
* Copyright 2025 The Kubernetes Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { Icon, InlineIcon } from '@iconify/react';
18+
import Box from '@mui/material/Box';
19+
import Button from '@mui/material/Button';
20+
import Dialog from '@mui/material/Dialog';
21+
import DialogContent from '@mui/material/DialogContent';
22+
import TextField from '@mui/material/TextField';
23+
import Typography from '@mui/material/Typography';
24+
import React from 'react';
25+
import { useTranslation } from 'react-i18next';
26+
import { DropZoneBox } from '../../cluster/KubeConfigLoader';
27+
import Tabs from '../Tabs';
28+
29+
const ActionButtons = ({
30+
onBack,
31+
onLoad,
32+
disabled,
33+
}: {
34+
onBack: () => void;
35+
onLoad: () => void;
36+
disabled?: boolean;
37+
}) => {
38+
const { t } = useTranslation();
39+
return (
40+
<Box sx={{ display: 'flex', mt: 2, justifyContent: 'space-between' }}>
41+
<Button onClick={onBack} color="inherit" variant="outlined">
42+
<Icon
43+
icon="mdi:arrow-left"
44+
width="18"
45+
height="18"
46+
style={{ display: 'inline-block', marginRight: '8px' }}
47+
/>
48+
{t('translation|Back')}
49+
</Button>
50+
<Button onClick={onLoad} color="inherit" variant="contained" disabled={disabled}>
51+
{t('translation|Load')}
52+
</Button>
53+
</Box>
54+
);
55+
};
56+
57+
interface UploadDialogProps {
58+
setUploadFiles: (value: boolean) => void;
59+
setCode: React.Dispatch<React.SetStateAction<{ code: string; format: string }>>;
60+
}
61+
62+
const UploadFromFilesystem = ({
63+
onLoaded,
64+
onCancel,
65+
}: {
66+
onLoaded: (text: string) => void;
67+
onCancel: () => void;
68+
}) => {
69+
const { t } = useTranslation();
70+
const [files, setFiles] = React.useState<File[]>([]);
71+
const [dragOver, setDragOver] = React.useState(false);
72+
const [error, setError] = React.useState('');
73+
74+
const onFilesPicked = (picked: FileList | null) => {
75+
setError('');
76+
setFiles(picked ? Array.from(picked) : []);
77+
};
78+
79+
const readFileAsText = (file: File) =>
80+
new Promise<string>((resolve, reject) => {
81+
const reader = new FileReader();
82+
reader.onload = e => resolve(String(e.target?.result ?? ''));
83+
reader.onerror = () => reject(new Error(t('translation|Failed to read file.')));
84+
reader.onabort = () => reject(new Error(t('translation|File read was aborted.')));
85+
reader.readAsText(file);
86+
});
87+
88+
const handleLoadFiles = async () => {
89+
try {
90+
setError('');
91+
if (files.every(f => f.size === 0)) {
92+
setError(t('translation|Error: All of the files are empty.'));
93+
}
94+
95+
const texts = await Promise.all(files.map(readFileAsText));
96+
const merged = texts.join('---\n');
97+
onLoaded(merged);
98+
} catch (e) {
99+
setError((e as Error).message || t('translation|Unexpected error while reading files.'));
100+
}
101+
};
102+
103+
const handleDrop = (e: React.DragEvent) => {
104+
e.preventDefault();
105+
setDragOver(false);
106+
onFilesPicked(e.dataTransfer.files);
107+
};
108+
109+
const handleDragOver = (e: React.DragEvent) => {
110+
e.preventDefault();
111+
setDragOver(true);
112+
};
113+
114+
const handleDragLeave = () => setDragOver(false);
115+
116+
return (
117+
<>
118+
<DropZoneBox onDrop={handleDrop} onDragOver={handleDragOver} onDragLeave={handleDragLeave}>
119+
<Typography sx={{ m: 2 }}>
120+
{dragOver
121+
? t('translation|Drop the file here...')
122+
: t('translation|Select a file or drag and drop here')}
123+
</Typography>
124+
<Button
125+
variant="contained"
126+
component="label"
127+
startIcon={<InlineIcon icon="mdi:upload" width={32} />}
128+
sx={{ fontWeight: 500 }}
129+
>
130+
{t('translation|Select File')}
131+
<input
132+
type="file"
133+
accept=".yaml,.yml,application/yaml"
134+
multiple
135+
hidden
136+
onChange={e => onFilesPicked(e.target.files)}
137+
/>
138+
</Button>
139+
</DropZoneBox>
140+
{!!files.length && (
141+
<Box
142+
sx={{
143+
borderRadius: 1,
144+
mt: 2,
145+
p: 1,
146+
width: '100%',
147+
border: '1px',
148+
fontWeight: 'bold',
149+
}}
150+
>
151+
{files.length === 1
152+
? files[0].name
153+
: t('translation|{{count}} files selected', { count: files.length })}{' '}
154+
{files.length > 1 && (
155+
<Typography variant="body2" component="div" sx={{ mt: 1 }}>
156+
{files.map(f => f.name).join(', ')}
157+
</Typography>
158+
)}
159+
</Box>
160+
)}
161+
{error && (
162+
<Typography sx={{ mt: 1 }} color="error">
163+
{error}
164+
</Typography>
165+
)}
166+
<ActionButtons onBack={onCancel} onLoad={handleLoadFiles} disabled={!files.length} />
167+
</>
168+
);
169+
};
170+
171+
const UploadFromUrl = ({
172+
onLoaded,
173+
onCancel,
174+
}: {
175+
onLoaded: (text: string) => void;
176+
onCancel: () => void;
177+
}) => {
178+
const { t } = useTranslation();
179+
const [url, setUrl] = React.useState('');
180+
const [error, setError] = React.useState('');
181+
182+
const parseUrl = (raw: string) => {
183+
try {
184+
const u = new URL(raw.trim());
185+
return u.protocol === 'http:' || u.protocol === 'https:' ? u : null;
186+
} catch {
187+
return null;
188+
}
189+
};
190+
191+
const loadFromUrl = async () => {
192+
setError('');
193+
const u = parseUrl(url);
194+
if (!u) {
195+
setError(t('translation|Please enter a valid URL.'));
196+
return;
197+
}
198+
199+
try {
200+
const res = await fetch(u.toString());
201+
if (!res.ok) {
202+
setError(t(`translation|Failed to fetch file: ${res.statusText}`));
203+
}
204+
const text = await res.text();
205+
onLoaded(text);
206+
} catch (e) {
207+
setError((e as Error).message || t('translation|Unexpected error while fetching the file.'));
208+
}
209+
};
210+
211+
return (
212+
<>
213+
<Box sx={{ pt: 1 }}>
214+
<TextField
215+
label={t('translation|Enter URL')}
216+
variant="outlined"
217+
fullWidth
218+
value={url}
219+
onChange={e => {
220+
setUrl(e.target.value);
221+
setError('');
222+
}}
223+
sx={{ mb: 2 }}
224+
error={!!error}
225+
/>
226+
</Box>
227+
{error && (
228+
<Typography sx={{ mt: 1 }} color="error">
229+
{error}
230+
</Typography>
231+
)}
232+
<ActionButtons onBack={onCancel} onLoad={loadFromUrl} disabled={!url} />
233+
</>
234+
);
235+
};
236+
237+
export function UploadDialog(props: UploadDialogProps) {
238+
const { setUploadFiles, setCode } = props;
239+
const { t } = useTranslation();
240+
241+
const finishLoad = (text: string) => {
242+
setCode({ format: 'yaml', code: text });
243+
setUploadFiles(false);
244+
};
245+
246+
return (
247+
<Dialog
248+
open
249+
onClose={() => setUploadFiles(false)}
250+
fullWidth
251+
slotProps={{
252+
backdrop: { sx: { backdropFilter: 'blur(2px)' } },
253+
}}
254+
>
255+
<DialogContent sx={{ pt: 1 }}>
256+
<Tabs
257+
ariaLabel={t('translation|Upload File/URL')}
258+
tabs={[
259+
{
260+
label: t('translation|Upload File'),
261+
component: (
262+
<Box pt={2}>
263+
<UploadFromFilesystem
264+
onLoaded={finishLoad}
265+
onCancel={() => setUploadFiles(false)}
266+
/>
267+
</Box>
268+
),
269+
},
270+
{
271+
label: t('translation|Load from URL'),
272+
component: (
273+
<Box pt={2}>
274+
<UploadFromUrl onLoaded={finishLoad} onCancel={() => setUploadFiles(false)} />
275+
</Box>
276+
),
277+
},
278+
]}
279+
/>
280+
</DialogContent>
281+
</Dialog>
282+
);
283+
}

frontend/src/components/common/Resource/__snapshots__/EditorDialog.EditorDialogWithResource.stories.storyshot

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,16 @@
126126
Use minimal editor
127127
</span>
128128
</label>
129+
<button
130+
class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary MuiButton-disableElevation MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary MuiButton-disableElevation css-gn8fa3-MuiButtonBase-root-MuiButton-root"
131+
tabindex="0"
132+
type="button"
133+
>
134+
Upload File/URL
135+
<span
136+
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
137+
/>
138+
</button>
129139
</div>
130140
</div>
131141
</div>

frontend/src/components/common/Resource/__snapshots__/EditorDialog.ExtraActions.stories.storyshot

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,16 @@
162162
Use minimal editor
163163
</span>
164164
</label>
165+
<button
166+
class="MuiButtonBase-root MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary MuiButton-disableElevation MuiButton-root MuiButton-contained MuiButton-containedPrimary MuiButton-sizeMedium MuiButton-containedSizeMedium MuiButton-colorPrimary MuiButton-disableElevation css-gn8fa3-MuiButtonBase-root-MuiButton-root"
167+
tabindex="0"
168+
type="button"
169+
>
170+
Upload File/URL
171+
<span
172+
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
173+
/>
174+
</button>
165175
</div>
166176
</div>
167177
</div>

frontend/src/components/common/Resource/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const checkExports = [
5151
'RestartMultipleButton',
5252
'ScaleButton',
5353
'SimpleEditor',
54+
'UploadDialog',
5455
'ViewButton',
5556
'AuthVisible',
5657
'LogsButton',

frontend/src/i18n/locales/de/translation.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@
263263
"Loading editor": "Editor laden",
264264
"Hide Managed Fields": "",
265265
"Use minimal editor": "Minimal-Editor verwenden",
266+
"Upload File/URL": "",
266267
"Editor": "Editor",
267268
"Documentation": "Dokumentation",
268269
"Undo": "Rückgängig machen",
@@ -336,6 +337,21 @@
336337
"Decrement": "Dekrementieren",
337338
"Increment": "Inkrementieren",
338339
"A large number of replicas may negatively impact the cluster's performance": "Eine große Anzahl von Replikas kann sich negativ auf die Leistung des Clusters auswirken",
340+
"Load": "",
341+
"Failed to read file.": "",
342+
"File read was aborted.": "",
343+
"Error: All of the files are empty.": "",
344+
"Unexpected error while reading files.": "",
345+
"Drop the file here...": "",
346+
"Select a file or drag and drop here": "",
347+
"Select File": "",
348+
"{{count}} files selected_one": "",
349+
"{{count}} files selected_other": "",
350+
"Please enter a valid URL.": "",
351+
"Unexpected error while fetching the file.": "",
352+
"Enter URL": "",
353+
"Upload File": "",
354+
"Load from URL": "",
339355
"View YAML": "YAML anzeigen",
340356
"Collapse": "Einklappen",
341357
"Expand": "Ausklappen",

0 commit comments

Comments
 (0)