Skip to content

Commit

Permalink
Merge pull request #35 from jbouder/thumbnails
Browse files Browse the repository at this point in the history
Update UI for Thumbnails
  • Loading branch information
aktech authored Jan 5, 2024
2 parents a5bad9b + 49ff79e commit 3300108
Show file tree
Hide file tree
Showing 11 changed files with 209 additions and 37 deletions.
9 changes: 8 additions & 1 deletion jhub_apps/service/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,15 @@ async def create_server(

@router.put("/server/{server_name}")
async def update_server(
server: ServerCreation, user: User = Depends(get_current_user), server_name=None
server: ServerCreation = Depends(Checker(ServerCreation)),
thumbnail: typing.Optional[UploadFile] = File(None),
user: User = Depends(get_current_user), server_name=None
):
if thumbnail:
thumbnail_contents = await thumbnail.read()
server.user_options.thumbnail = encode_file_to_data_url(
thumbnail.filename, thumbnail_contents
)
hub_client = HubClient()
return hub_client.create_server(
username=user.name,
Expand Down
2 changes: 1 addition & 1 deletion jhub_apps/static/css/index.css

Large diffs are not rendered by default.

34 changes: 17 additions & 17 deletions jhub_apps/static/js/index.js

Large diffs are not rendered by default.

13 changes: 10 additions & 3 deletions jhub_apps/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,20 @@ def test_api_update_server(create_server, client):
create_server_response = {"user": "aktech"}
create_server.return_value = create_server_response
user_options = mock_user_options()
body = {"servername": "panel-app", "user_options": user_options}
response = client.put("/server/panel-app", json=body)
thumbnail = b"contents of thumbnail"
in_memory_file = io.BytesIO(thumbnail)
response = client.put(
"/server/panel-app",
data={'data': json.dumps({"servername": "panel-app", "user_options": user_options})},
files={'thumbnail': ('image.jpeg', in_memory_file)}
)
final_user_options = UserOptions(**user_options)
final_user_options.thumbnail = "data:image/jpeg;base64,Y29udGVudHMgb2YgdGh1bWJuYWls"
create_server.assert_called_once_with(
username=MOCK_USER.name,
servername="panel-app",
edit=True,
user_options=UserOptions(**user_options),
user_options=final_user_options,
)
assert response.json() == create_server_response

Expand Down
30 changes: 30 additions & 0 deletions ui/src/components/file-input/file-input.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import '@testing-library/jest-dom';
import { render } from '@testing-library/react';
import { FileInput } from './file-input';

describe('FileInput', () => {
test('renders a default file input successfully', () => {
const { baseElement } = render(<FileInput id="input" name="input" />);
const input = baseElement.querySelector('input');

expect(input).toBeInTheDocument();
});

test('renders a file input with multiple files allowed', () => {
const { baseElement } = render(<FileInput id="input" allowMultiple />);
const input = baseElement.querySelector('input');

expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('multiple');
});

test('renders a file input with allowed file types', () => {
const { baseElement } = render(
<FileInput id="input" allowedFileTypes="image/png" />,
);
const input = baseElement.querySelector('input');

expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('accept', 'image/png');
});
});
48 changes: 48 additions & 0 deletions ui/src/components/file-input/file-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import classnames from 'classnames';
import React from 'react';

export interface FileInputProps {
/**
* The unique identifier for this component
*/
id: string;
/**
* The name for the file input field
*/
name?: string;
/**
* Whether or not to allow multiple files to be uploaded
*/
allowMultiple?: boolean;
/**
* The types of files that are allowed to be uploaded
*/
allowedFileTypes?: string;
}

/**
* File input allows users to attach one or multiple files.
*/
export const FileInput = ({
id,
name,
className,
allowMultiple = undefined,
allowedFileTypes = 'image/png, image/jpeg',
...props
}: FileInputProps & JSX.IntrinsicElements['input']): React.ReactElement => {
const classes = classnames('file-input', className);
return (
<input
id={id}
name={name}
className={classes}
type="file"
multiple={allowMultiple}
accept={allowedFileTypes}
{...props}
/>
);
};

export default FileInput;
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 ButtonGroup } from './button-group/button-group';
export { default as Button } from './button/button';
export { default as ContextMenu } from './context-menu/context-menu';
export { default as ErrorMessages } from './error-messages/error-messages';
export { default as FileInput } from './file-input/file-input';
export { default as FormGroup } from './form-group/form-group';
export { default as Label } from './label/label';
export { default as Modal } from './modal/modal';
Expand Down
23 changes: 12 additions & 11 deletions ui/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -94,36 +94,31 @@
}

.text-input,
.file-input,
.text-area,
.select {
width: 100%;
margin-top: 0.5rem;
}

.text-input,
.text-area {
.text-area,
.select {
border: 1px solid theme('colors.gray-darkest');
border-radius: 0;
}

.text-input:focus,
.text-area:focus {
.file-input:focus,
.text-area:focus,
.select:focus {
outline: 0.2rem solid theme('colors.primary-light');
}

.text-area {
min-height: 6rem;
}

.select {
border: 1px solid theme('colors.gray-darkest');
border-radius: 0;
}

.select:focus {
outline: 0.2rem solid theme('colors.primary-light');
}

.text-red {
color: theme('colors.error');
}
Expand Down Expand Up @@ -161,6 +156,10 @@
overflow: hidden;
}

.card-header-img > img {
margin: auto auto;
}

.card-body {
min-height: 75px;
padding: 0.5rem 1.5rem;
Expand Down Expand Up @@ -234,6 +233,8 @@

.modal-body {
min-height: 120px;
max-height: 90vh;
overflow-y: auto;
color: theme('colors.text-dark');
padding: 16px 24px;
font-size: 0.93rem;
Expand Down
2 changes: 1 addition & 1 deletion ui/src/pages/home/app-card/app-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export const AppCard = ({
/>
)}
</div>
<div className="card-header-img">
<div className="card-header-img flex flex-row">
{thumbnail ? <img src={thumbnail} alt="App thumb" /> : undefined}
</div>
</div>
Expand Down
43 changes: 43 additions & 0 deletions ui/src/pages/home/app-form/app-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,49 @@ describe('AppForm', () => {
}
});

test('simulates creating a standard app with thumbnail', async () => {
mock.onGet(new RegExp('/frameworks')).reply(200, frameworks);
mock.onPost(new RegExp('/server')).reply(200);
queryClient.setQueryData(['app-frameworks'], frameworks);
const { baseElement } = render(
<RecoilRoot>
<QueryClientProvider client={queryClient}>
<AppForm />
</QueryClientProvider>
</RecoilRoot>,
);

const displayNameField = baseElement.querySelector(
'#display_name',
) as HTMLInputElement;
const thumbnailField = baseElement.querySelector(
'#thumbnail',
) as HTMLInputElement;
const frameworkField = baseElement.querySelector(
'#framework',
) as HTMLSelectElement;
if (displayNameField && thumbnailField && frameworkField) {
// Attempt submitting without filling in required fields
const btn = baseElement.querySelector('#submit-btn') as HTMLButtonElement;
await act(async () => {
btn.click();
});

const file = new File(['File contents'], 'image.png', {
type: 'image/png',
});
await userEvent.upload(thumbnailField, file);

await userEvent.type(displayNameField, 'App 1');
fireEvent.change(frameworkField, { target: { value: 'panel' } });

// Submit with all required fields filled in
await act(async () => {
btn.click();
});
}
});

test('simulates creating a standard app with onSubmit', async () => {
mock.onGet(new RegExp('/frameworks')).reply(200, frameworks);
mock.onPost(new RegExp('/server')).reply(200);
Expand Down
41 changes: 38 additions & 3 deletions ui/src/pages/home/app-form/app-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
Button,
ButtonGroup,
ErrorMessages,
FileInput,
FormGroup,
Label,
Select,
Expand All @@ -41,6 +42,7 @@ export const AppForm = ({
currentNotification,
);
const [name, setName] = useState('');
const [currentFile, setCurrentFile] = useState<File>();
// Get the app data if we're editing an existing app
const { data: formData, error: formError } = useQuery<
AppQueryGetProps,
Expand Down Expand Up @@ -172,6 +174,10 @@ export const AppForm = ({
};
const formData = new FormData();
formData.append('data', JSON.stringify({ servername, user_options }));
if (currentFile) {
formData.append('thumbnail', currentFile as Blob);
}

const response = await axios.post('/server', formData, { headers });
return response.data;
};
Expand All @@ -180,9 +186,18 @@ export const AppForm = ({
servername,
user_options,
}: AppQueryUpdateProps) => {
const response = await axios.put(`/server/${servername}`, {
servername,
user_options,
const headers = {
accept: 'application/json',
'Content-Type': 'multipart/form-data',
};
const formData = new FormData();
formData.append('data', JSON.stringify({ servername, user_options }));
if (currentFile) {
formData.append('thumbnail', currentFile as Blob);
}

const response = await axios.put(`/server/${servername}`, formData, {
headers,
});
return response.data;
};
Expand Down Expand Up @@ -246,6 +261,26 @@ export const AppForm = ({
)}
/>
</FormGroup>
<FormGroup>
<Label htmlFor="thumbnail">Thumbnail</Label>
<Controller
name="thumbnail"
control={control}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
render={({ field: { ref: _, value, onChange, ...field } }) => (
<FileInput
{...field}
id="thumbnail"
value={undefined}
onChange={(event) => {
const { files } = event.target;
const selectedFiles = files as FileList;
setCurrentFile(selectedFiles?.[0]);
}}
/>
)}
/>
</FormGroup>
<FormGroup
errors={
errors.framework?.message ? [errors.framework.message] : undefined
Expand Down

0 comments on commit 3300108

Please sign in to comment.