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

#3060-Edit Organization Logo #3065

Merged
merged 18 commits into from
Dec 21, 2024
Merged
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
2 changes: 2 additions & 0 deletions src/backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import workPackageTemplatesRouter from './src/routes/work-package-templates.rout
import carsRouter from './src/routes/cars.routes';
import organizationRouter from './src/routes/organizations.routes';
import recruitmentRouter from './src/routes/recruitment.routes';
import onboardingRouter from './src/routes/onboarding.routes';

const app = express();

Expand Down Expand Up @@ -68,6 +69,7 @@ app.use('/templates', workPackageTemplatesRouter);
app.use('/cars', carsRouter);
app.use('/organizations', organizationRouter);
app.use('/recruitment', recruitmentRouter);
app.use('/onboarding', onboardingRouter);
app.use('/', (_req, res) => {
res.status(200).json('Welcome to FinishLine');
});
Expand Down
21 changes: 21 additions & 0 deletions src/backend/src/controllers/onboarding.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { NextFunction, Request, Response } from 'express';
import OnboardingServices from '../services/onboarding.services';

export default class OnboardingController {
static async downloadImage(req: Request, res: Response, next: NextFunction) {
try {
const { fileId } = req.params;

const imageData = await OnboardingServices.downloadImage(fileId);

// Set the appropriate headers for the HTTP response
res.setHeader('content-type', String(imageData.type));
res.setHeader('content-length', imageData.buffer.length);

// Send the Buffer as the response body
res.send(imageData.buffer);
} catch (error: unknown) {
return next(error);
}
}
}
1 change: 1 addition & 0 deletions src/backend/src/controllers/organizations.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export default class OrganizationsController {
if (!req.file) {
throw new HttpException(400, 'Invalid or undefined image data');
}

const updatedOrg = await OrganizationsService.setLogoImage(req.file, req.currentUser, req.organization);

res.status(200).json(updatedOrg);
Expand Down
8 changes: 8 additions & 0 deletions src/backend/src/routes/onboarding.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import express from 'express';
import OnboardingController from '../controllers/onboarding.controller';

const onboardingRouter = express.Router();

onboardingRouter.get('/image/:fileId', OnboardingController.downloadImage);

export default onboardingRouter;
12 changes: 12 additions & 0 deletions src/backend/src/services/onboarding.services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { NotFoundException } from '../utils/errors.utils';
import { downloadImageFile } from '../utils/google-integration.utils';

export default class OnboardingServices {
static async downloadImage(fileId: string) {
const fileData = await downloadImageFile(fileId);
console.log('FILE DATA RECEIVED');

if (!fileData) throw new NotFoundException('Image File', fileId);
return fileData;
}
}
10 changes: 5 additions & 5 deletions src/backend/src/services/organizations.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,10 @@ export default class OrganizationsService {

const logoImageData = await uploadFile(logoImage);

if (!logoImageData?.name) {
throw new HttpException(500, 'Image Name not found');
}

const updatedOrg = await prisma.organization.update({
where: { organizationId: organization.organizationId },
data: {
Expand All @@ -216,7 +220,7 @@ export default class OrganizationsService {
* @param organizationId the id of the organization
* @returns the id of the image
*/
static async getLogoImage(organizationId: string): Promise<string> {
static async getLogoImage(organizationId: string): Promise<string | null> {
const organization = await prisma.organization.findUnique({
where: { organizationId }
});
Expand All @@ -225,10 +229,6 @@ export default class OrganizationsService {
throw new NotFoundException('Organization', organizationId);
}

if (!organization.logoImageId) {
throw new HttpException(404, `Organization ${organizationId} does not have a logo image`);
}

return organization.logoImageId;
}

Expand Down
10 changes: 2 additions & 8 deletions src/backend/tests/unmocked/organization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ describe('Organization Tests', () => {
it('Succeeds and updates all the images', async () => {
const testBatman = await createTestUser(batmanAppAdmin, orgId);
(uploadFile as Mock).mockImplementation((file) => {
return Promise.resolve({ id: `uploaded-${file.originalname}` });
return Promise.resolve({ name: `${file.originalname}`, id: `uploaded-${file.originalname}` });
});

await OrganizationsService.setImages(file1, file2, testBatman, organization);
Expand Down Expand Up @@ -240,7 +240,7 @@ describe('Organization Tests', () => {
it('Succeeds and updates the logo', async () => {
const testBatman = await createTestUser(batmanAppAdmin, orgId);
(uploadFile as Mock).mockImplementation((file) => {
return Promise.resolve({ id: `uploaded-${file.originalname}` });
return Promise.resolve({ name: `${file.originalname}`, id: `uploaded-${file.originalname}` });
});

await OrganizationsService.setLogoImage(file1, testBatman, organization);
Expand Down Expand Up @@ -273,12 +273,6 @@ describe('Organization Tests', () => {
);
});

it('Fails if the organization does not have a logo image', async () => {
await expect(async () => await OrganizationsService.getLogoImage(orgId)).rejects.toThrow(
new HttpException(404, `Organization ${orgId} does not have a logo image`)
);
});

it('Succeeds and gets the image', async () => {
const testBatman = await createTestUser(batmanAppAdmin, orgId);
await OrganizationsService.setLogoImage(
Expand Down
16 changes: 16 additions & 0 deletions src/frontend/src/apis/onboarding.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import axios from 'axios';
import { apiUrls } from '../utils/urls';

/**
* API Call to download a google image
* @param fileId file id to be downloaded
* @returns an image blob
*/
export const downloadGoogleImage = async (fileId: string): Promise<Blob> => {
const response = await axios.get(apiUrls.imageById(fileId), {
responseType: 'arraybuffer' // Set the response type to 'arraybuffer' to receive the image as a Buffer
});
const imageBuffer = new Uint8Array(response.data);
const imageBlob = new Blob([imageBuffer], { type: response.headers['content-type'] });
return imageBlob;
};
27 changes: 27 additions & 0 deletions src/frontend/src/apis/organizations.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,35 @@ export const setOrganizationDescription = async (description: string) => {
});
};

export const getOrganizationLogo = async () => {
return axios.get<string>(apiUrls.organizationsLogoImage(), {
transformResponse: (data) => JSON.parse(data)
});
};

export const setOrganizationLogo = async (file: File) => {
const formData = new FormData();
formData.append('logo', file);
return axios.post(apiUrls.organizationsSetLogoImage(), formData);
};

export const setOrganizationFeaturedProjects = async (featuredProjectIds: string[]) => {
return axios.post<Organization>(apiUrls.organizationsSetFeaturedProjects(), {
projectIds: featuredProjectIds
});
};

/**
* Downloads a given fileId from google drive into a blob
*
* @param fileId the google id of the file to download
* @returns the downloaded file as a Blob
*/
export const downloadGoogleImage = async (fileId: string): Promise<Blob> => {
const response = await axios.get(apiUrls.imageById(fileId), {
responseType: 'arraybuffer' // Set the response type to 'arraybuffer' to receive the image as a Buffer
});
const imageBuffer = new Uint8Array(response.data);
const imageBlob = new Blob([imageBuffer], { type: response.headers['content-type'] });
return imageBlob;
};
22 changes: 22 additions & 0 deletions src/frontend/src/hooks/organizations.hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import {
getFeaturedProjects,
getCurrentOrganization,
setOrganizationDescription,
getOrganizationLogo,
setOrganizationLogo,
setOrganizationFeaturedProjects
} from '../apis/organizations.api';
import { downloadGoogleImage } from '../apis/organizations.api';

interface OrganizationProvider {
organizationId: string;
Expand Down Expand Up @@ -84,3 +87,22 @@ export const useSetFeaturedProjects = () => {
}
);
};

export const useSetOrganizationLogo = () => {
const queryClient = useQueryClient();
return useMutation<Organization, Error, File>(['reimbursement-requsts', 'edit'], async (file: File) => {
const { data } = await setOrganizationLogo(file);
queryClient.invalidateQueries(['organizations']);
return data;
});
};

export const useOrganizationLogo = () => {
return useQuery<Blob | undefined, Error>(['organizations', 'logo'], async () => {
const { data: fileId } = await getOrganizationLogo();
if (!fileId) {
return;
}
return await downloadGoogleImage(fileId);
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const EditDescription: React.FC = () => {
return (
<Card
sx={{
width: { xs: '100%', md: '50%' },
width: '100%',
background: 'transparent',
padding: 2,
...(isEditMode && {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const EditFeaturedProjects = () => {
return (
<Card
sx={{
width: { xs: '100%', md: '50%' },
width: '100%',
background: 'transparent',
padding: 2,
...(isEditMode && {
Expand Down
93 changes: 93 additions & 0 deletions src/frontend/src/pages/AdminToolsPage/EditGuestView/EditLogo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React, { useState } from 'react';
import { useCurrentOrganization, useOrganizationLogo, useSetOrganizationLogo } from '../../../hooks/organizations.hooks';
import LoadingIndicator from '../../../components/LoadingIndicator';
import EditLogoForm, { EditLogoInput } from './EditLogoForm';
import { useToast } from '../../../hooks/toasts.hooks';
import { Box, Card, Typography, useTheme } from '@mui/material';
import LogoDisplay from '../../HomePage/components/LogoDisplay';
import { NERButton } from '../../../components/NERButton';
import ErrorPage from '../../ErrorPage';

const EditLogo = () => {
const {
data: organization,
isLoading: organizationIsLoading,
isError: organizationIsError,
error: organizationError
} = useCurrentOrganization();
const { data: imageData, isLoading: imageDataIsLoading, isError: imageIsError, error: imageError } = useOrganizationLogo();
const { mutateAsync, isLoading } = useSetOrganizationLogo();
const toast = useToast();
const [isEditMode, setIsEditMode] = useState(false);
const theme = useTheme();

if (isLoading || !mutateAsync || organizationIsLoading || !organization || imageDataIsLoading) return <LoadingIndicator />;
if (organizationIsError) return <ErrorPage message={organizationError.message} />;
if (imageIsError) return <ErrorPage message={imageError.message} />;

const handleClose = () => {
setIsEditMode(false);
};

const onSubmit = async (logoInput: EditLogoInput) => {
try {
if (!logoInput.logoImage) {
toast.error('No logo image submitted.');
handleClose();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should probably toast why were closing

return;
}
await mutateAsync(logoInput.logoImage);
toast.success('Logo updated successfully!');
} catch (e) {
if (e instanceof Error) {
toast.error(e.message);
}
}
handleClose();
};

return (
<Card
sx={{
width: '100%',
background: 'transparent',
padding: 2,
...(isEditMode && {
background: theme.palette.background.paper,
padding: 1.9,
variant: 'outlined'
})
}}
variant={isEditMode ? 'outlined' : undefined}
>
<Typography variant="h4" mb={1}>
{organization.name} Logo
</Typography>
{isEditMode ? (
<EditLogoForm
onSubmit={onSubmit}
onHide={handleClose}
orgLogo={imageData ? new File([imageData], imageData.name, { type: imageData.type }) : undefined}
/>
) : (
<>
<Box sx={{ display: 'flex', flexDirection: 'column', height: 350, width: 300 }}>
<LogoDisplay imageUrl={imageData ? URL.createObjectURL(imageData) : undefined} />
<Box
sx={{
display: 'flex',
justifyContent: 'end'
}}
>
<NERButton variant="contained" sx={{ my: 2 }} onClick={() => setIsEditMode(true)}>
Update
</NERButton>
</Box>
</Box>
</>
)}
</Card>
);
};

export default EditLogo;
Loading
Loading