Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update App Launcher Form Environment Variables Component based on new design #289

Merged
merged 6 commits into from
May 24, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
60 changes: 30 additions & 30 deletions jhub_apps/static/js/index.js

Large diffs are not rendered by default.

47 changes: 20 additions & 27 deletions ui/src/components/app-form/app-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,17 @@ import { AppFormInput } from '@src/types/form';
import { UserState } from '@src/types/user';
import axios from '@src/utils/axios';
import { APP_BASE_URL, REQUIRED_FORM_FIELDS_RULES } from '@src/utils/constants';
import { getFriendlyDisplayName, navigateToUrl } from '@src/utils/jupyterhub';
import {
getFriendlyDisplayName,
getFriendlyEnvironmentVariables,
navigateToUrl,
} from '@src/utils/jupyterhub';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useRecoilState } from 'recoil';
import { AppSharing, Thumbnail } from '..';
import { AppSharing, EnvironmentVariables, Thumbnail } from '..';
import {
currentNotification,
currentFile as defaultFile,
Expand Down Expand Up @@ -86,6 +90,7 @@ export const AppForm = ({ id }: AppFormProps): React.ReactElement => {
string[]
>([]);
const [keepAlive, setKeepAlive] = useState(false);
const [variables, setVariables] = useState<string | null>(null);
// Get the app data if we're editing an existing app
const { data: formData, error: formError } = useQuery<
AppQueryGetProps,
Expand Down Expand Up @@ -146,7 +151,6 @@ export const AppForm = ({ id }: AppFormProps): React.ReactElement => {
thumbnail: '',
filepath: '',
conda_env: '',
env: '',
custom_command: '',
profile: '',
is_public: false,
Expand Down Expand Up @@ -189,7 +193,6 @@ export const AppForm = ({ id }: AppFormProps): React.ReactElement => {
thumbnail,
filepath,
conda_env,
env,
custom_command,
profile,
}) => {
Expand All @@ -203,7 +206,7 @@ export const AppForm = ({ id }: AppFormProps): React.ReactElement => {
thumbnail,
filepath,
conda_env,
env: env ? JSON.parse(env) : null,
env: getFriendlyEnvironmentVariables(variables),
custom_command,
profile,
is_public: isPublic,
Expand All @@ -227,7 +230,7 @@ export const AppForm = ({ id }: AppFormProps): React.ReactElement => {
thumbnail: thumbnail || '',
filepath: filepath || '',
conda_env: conda_env || '',
env: env ? JSON.parse(env) : null,
env: getFriendlyEnvironmentVariables(variables),
custom_command: custom_command || '',
profile: profile || '',
public: isPublic,
Expand Down Expand Up @@ -329,12 +332,10 @@ export const AppForm = ({ id }: AppFormProps): React.ReactElement => {
setDescription(formData.user_options.description);
reset({
...formData.user_options,
env: formData.user_options.env
? JSON.stringify(formData.user_options.env)
: undefined,
});
setIsPublic(formData.user_options.public);
setKeepAlive(formData.user_options.keep_alive);
setVariables(formData.user_options.env || null);
setCurrentImage(formData.user_options.thumbnail);
setCurrentUserPermissions(formData.user_options.share_with?.users);
setCurrentGroupPermissions(formData.user_options.share_with?.groups);
Expand All @@ -358,14 +359,12 @@ export const AppForm = ({ id }: AppFormProps): React.ReactElement => {
framework: currentFormInput.framework || '',
filepath: currentFormInput.filepath || '',
conda_env: currentFormInput.conda_env || '',
env: currentFormInput.env
? JSON.stringify(currentFormInput.env)
: undefined,
custom_command: currentFormInput.custom_command || '',
profile: currentFormInput.profile || '',
});
setIsPublic(currentFormInput.is_public);
setKeepAlive(currentFormInput.keep_alive);
setVariables(currentFormInput.env || null);
setCurrentImage(currentFormInput.thumbnail);
setCurrentUserPermissions(currentFormInput.share_with?.users);
setCurrentGroupPermissions(currentFormInput.share_with?.groups);
Expand Down Expand Up @@ -574,21 +573,6 @@ export const AppForm = ({ id }: AppFormProps): React.ReactElement => {
</FormControl>
)}
/>
<Controller
name="env"
control={control}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
render={({ field: { ref: _, ...field } }) => (
<FormControl>
<TextField
{...field}
id="env"
label="Environment Variables"
placeholder={`Enter valid json: {"KEY_1":"VALUE_1","KEY_2":"VALUE_2"}`}
/>
</FormControl>
)}
/>
<Box
sx={{
display: 'flex',
Expand Down Expand Up @@ -640,6 +624,15 @@ export const AppForm = ({ id }: AppFormProps): React.ReactElement => {
/>
</Box>
</StyledFormSection>
<StyledFormSection>
<StyledFormSectionHeading>
Environment Variables
</StyledFormSectionHeading>
<EnvironmentVariables
variables={variables}
setVariables={setVariables}
/>
</StyledFormSection>
<StyledFormSection>
<StyledFormSectionHeading>Sharing</StyledFormSectionHeading>
<AppSharing
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { act, fireEvent, render, waitFor } from '@testing-library/react';
import { EnvironmentVariables } from '..';

describe('EnvironmentVariables', () => {
test('renders default successfully', () => {
const { baseElement } = render(
<EnvironmentVariables variables={null} setVariables={jest.fn()} />,
);

expect(baseElement).toBeTruthy();
});

test('renders with mock data', async () => {
const { baseElement } = render(
<EnvironmentVariables
variables="{'key':'value'}"
setVariables={jest.fn()}
/>,
);

waitFor(() => {
const rows = baseElement.querySelectorAll('attr[name="key"]');
expect(rows).toHaveLength(1);
});
});

test('Adds a new row', async () => {
const { baseElement, getByText } = render(
<EnvironmentVariables variables={null} setVariables={jest.fn()} />,
);

const button = getByText('Add Variable');
if (button) {
button?.click();
}

waitFor(() => {
const rows = baseElement.querySelectorAll('attr[name="key"]');
expect(rows).toHaveLength(1);
});
});

test('Removes a row', async () => {
const { baseElement, getAllByTestId } = render(
<EnvironmentVariables
variables="{'key':'value'}"
setVariables={jest.fn()}
/>,
);

const button = getAllByTestId('CloseRoundedIcon')[0];
if (button) {
await act(async () => {
(button.parentNode as HTMLButtonElement)?.click();
});
}

waitFor(() => {
const rows = baseElement.querySelectorAll('attr[name="key"]');
expect(rows).toHaveLength(0);
});
});

test('Updates a row', async () => {
const { baseElement } = render(
<EnvironmentVariables
variables="{'key':'value'}"
setVariables={jest.fn()}
/>,
);
let input = baseElement.querySelector(
'#environment-variable-key-0',
) as HTMLButtonElement;
if (input) {
await act(async () => {
fireEvent.change(input, { target: { value: 'new key' } });
});
expect(input.value).toBe('new key');
}

input = baseElement.querySelector(
'#environment-variable-value-0',
) as HTMLButtonElement;
if (input) {
await act(async () => {
fireEvent.change(input, { target: { value: 'new value' } });
});
expect(input.value).toBe('new value');
}
});
});
133 changes: 133 additions & 0 deletions ui/src/components/environment-variables/environment-variables.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import AddIcon from '@mui/icons-material/AddRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import { Box, Button, IconButton, Stack, TextField } from '@mui/material';
import { KeyValuePair } from '@src/types/api';
import React, { ChangeEvent, useEffect, useState } from 'react';
import { Item } from '../../styles/styled-item';

export interface EnvironmentVariablesProps {
variables: string | null;
setVariables: React.Dispatch<React.SetStateAction<string | null>>;
}

export const EnvironmentVariables = ({
variables,
setVariables,
}: EnvironmentVariablesProps): React.ReactElement => {
const [rows, setRows] = useState<KeyValuePair[]>([]);
const getVariables = (values: KeyValuePair[]) => {
if (!values || values.length === 0) {
return null;
}

/* eslint-disable @typescript-eslint/no-explicit-any */
const newVariables = {} as any;
values.forEach((row) => {
if (row.key) {
newVariables[row.key] = row.value;
}
});
return newVariables;
};

const handleChange = (
index: number,
event: ChangeEvent<HTMLInputElement>,
) => {
const values = [...rows];
if (event.target.name === 'key') {
values[index].key = event.target.value;
} else {
values[index].value = event.target.value;
}
setRows(values);
setVariables(getVariables(values));
};

const handleAddRow = () => {
setRows([...rows, { key: '', value: '' }]);
};

const handleRemoveRow = (index: number) => {
const values = [...rows];
values.splice(index, 1);
setRows(values);
setVariables(getVariables(values));
};

// Only load previous variables the first time
useEffect(() => {
if (variables && rows.length === 0) {
setRows(() => {
const newRows = [];
for (const [key, value] of Object.entries(variables)) {
newRows.push({ key, value });
}
return newRows;
});
}
}, [variables, rows.length]);

return (
<Box id="environment-variables">
<Stack>
<Item sx={{ pb: '16px' }}>
{rows.map((row, index) => (
<Stack
direction="row"
gap={1}
key={`environment-variable-row-${index}`}
sx={{ pb: '16px' }}
>
<Item sx={{ width: '100%' }}>
<TextField
id={`environment-variable-key-${index}`}
name="key"
label="Key"
placeholder="Key"
value={row.key}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
handleChange(index, e)
}
/>
</Item>
<Item sx={{ width: '100%' }}>
<TextField
id={`environment-variable-value-${index}`}
name="value"
label="Value"
placeholder="Value"
value={row.value}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
handleChange(index, e)
}
/>
</Item>
<Item>
<IconButton
sx={{ mt: '7px' }}
onClick={() => handleRemoveRow(index)}
aria-label="Remove"
>
<CloseRoundedIcon />
</IconButton>
</Item>
</Stack>
))}
</Item>
<Item>
<Button
variant="contained"
color="secondary"
startIcon={<AddIcon />}
onClick={handleAddRow}
>
Add Variable
</Button>
</Item>
</Stack>
</Box>
);
};

export default EnvironmentVariables;
1 change: 1 addition & 0 deletions ui/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export { default as AppForm } from './app-form/app-form';
export { default as AppSharing } from './app-sharing/app-sharing';
export { default as ButtonGroup } from './button-group/button-group';
export { default as ContextMenu } from './context-menu/context-menu';
export { default as EnvironmentVariables } from './environment-variables/environment-variables';
export { default as Navigation } from './navigation/navigation';
export { default as NotificationBar } from './notification-bar/notification-bar';
export { default as StatusChip } from './status-chip/status-chip';
Expand Down
2 changes: 1 addition & 1 deletion ui/src/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export const serverApps = {
framework: 'streamlit',
conda_env: 'env-2',
profile: 'small0',
env: { key: 'value' },
env: { key1: 'value1', key2: 'value2' },
public: false,
share_with: {
users: [],
Expand Down
7 changes: 5 additions & 2 deletions ui/src/pages/server-types/server-types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import { AppFormInput } from '@src/types/form';
import { UserState } from '@src/types/user';
import axios from '@src/utils/axios';
import { APP_BASE_URL } from '@src/utils/constants';
import { navigateToUrl } from '@src/utils/jupyterhub';
import {
getFriendlyEnvironmentVariables,
navigateToUrl,
} from '@src/utils/jupyterhub';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import React, { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
Expand Down Expand Up @@ -100,7 +103,7 @@ export const ServerTypes = (): React.ReactElement => {
thumbnail: currentFormInput?.thumbnail || '',
filepath: currentFormInput?.filepath || '',
conda_env: currentFormInput?.conda_env || '',
env: currentFormInput?.env ? JSON.parse(currentFormInput.env) : null,
env: getFriendlyEnvironmentVariables(currentFormInput?.env),
custom_command: currentFormInput?.custom_command || '',
profile: currentFormInput?.profile || '',
public: currentFormInput?.is_public || false,
Expand Down
Loading
Loading