Skip to content

Commit

Permalink
Sk.mn.reject image2 (#358)
Browse files Browse the repository at this point in the history
* Added component for filter

* Add skeleton component, params, api route to client, basic outline

* Fix compilation errors

* Fix site id

* Add list view of image

* Cannot read properties of undefined map

* Start selection

* Tested filter site images

* Fix null

* Cleanup

* Add frontend implementation of approving and rejecting images

* Address review comments

* fix translation changes

* Add loading functionality and clear filters functionality

* working: translations + styling + tests

* working: need to finalize design

* tests + cleanup

* tests

* disable submit when no reason

* initial value

* remove usecallback + switch to dropdown

* Fix so that the table works with no unapproved images

* correct reject route

* Added reason

---------

Co-authored-by: SurabhiKeesara <surabhi.keesara@gmail>
Co-authored-by: Avery Huang <[email protected]>
  • Loading branch information
3 people authored Jun 2, 2024
1 parent 9f879f5 commit d9182fd
Show file tree
Hide file tree
Showing 9 changed files with 614 additions and 42 deletions.
50 changes: 50 additions & 0 deletions src/api/protectedApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ import {
TemplateNamesResponse,
LoadTemplateResponse,
} from '../containers/email/types';
import {
FilterSiteImagesParams,
FilterSiteImagesResponse,
} from '../containers/reviewImages/types';

export interface ProtectedApiExtraArgs {
readonly protectedApiClient: ProtectedApiClient;
Expand Down Expand Up @@ -135,6 +139,11 @@ export interface ProtectedApiClient {
readonly filterSites: (
params: FilterSitesParams,
) => Promise<FilterSitesResponse>;
readonly filterSiteImages: (
params: FilterSiteImagesParams,
) => Promise<FilterSiteImagesResponse>;
readonly approveImage: (imageId: number) => Promise<void>;
readonly rejectImage: (imageId: number, reason: string) => Promise<void>;
readonly uploadImage: (
siteEntryId: number,
imageFile: string | ArrayBuffer,
Expand Down Expand Up @@ -251,6 +260,24 @@ export const ParameterizedAdminApiRoutes = {
? `&activityCountMax=${params.activityCountMax}`
: ''
}`,
FILTER_SITE_IMAGES: (params: FilterSiteImagesParams): string =>
`${baseSiteRoute}unapproved_images${
params.siteIds ||
params.submittedStart ||
params.submittedEnd ||
params.neighborhoods
? '?'
: ''
}
${params.siteIds ? `&siteIds=${params.siteIds}` : ''}${
params.submittedStart ? `&submittedStart=${params.submittedStart}` : ''
}${params.submittedEnd ? `&submittedEnd=${params.submittedEnd}` : ''}${
params.neighborhoods ? `&neighborhoodIds=${params.neighborhoods}` : ''
}`,
APPROVE_IMAGE: (imageId: number): string =>
`api/v1/protected/sites/approve_image/${imageId}`,
REJECT_IMAGE: (imageId: number, reason: string): string =>
`api/v1/protected/sites/reject_image/${imageId}?reason=${reason}`,
LOAD_TEMPLATE: (templateName: string): string =>
`api/v1/protected/emailer/load_template/${templateName}`,
};
Expand Down Expand Up @@ -598,6 +625,26 @@ const filterSites = (
).then((res) => res.data);
};

const filterSiteImages = (
params: FilterSiteImagesParams,
): Promise<FilterSiteImagesResponse> => {
return AppAxiosInstance.get(
ParameterizedAdminApiRoutes.FILTER_SITE_IMAGES(params),
).then((res) => res.data);
};

const approveImage = (imageId: number): Promise<void> => {
return AppAxiosInstance.put(
ParameterizedAdminApiRoutes.APPROVE_IMAGE(imageId),
).then((res) => res.data);
};

const rejectImage = (imageId: number, reason: string): Promise<void> => {
return AppAxiosInstance.delete(
ParameterizedAdminApiRoutes.REJECT_IMAGE(imageId, reason),
).then((res) => res.data);
};

const uploadImage = (
siteEntryId: number,
imageFile: string | ArrayBuffer,
Expand Down Expand Up @@ -688,6 +735,9 @@ const Client: ProtectedApiClient = Object.freeze({
sendEmail,
deleteImage,
filterSites,
filterSiteImages,
approveImage,
rejectImage,
uploadImage,
getEmailTemplateNames,
loadEmailTemplateContent,
Expand Down
63 changes: 63 additions & 0 deletions src/api/test/protectedApiClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2245,6 +2245,69 @@ describe('Admin Protected Client Routes', () => {
});
});

describe('filterSiteImages', () => {
it('makes the right request', async () => {
const response = '';

const params = {
submittedStart: null,
submittedEnd: null,
neighborhoods: [0, 1, 2],
siteIds: [0, 1, 2, 3],
};

nock(BASE_URL)
.get(ParameterizedAdminApiRoutes.FILTER_SITE_IMAGES(params))
.reply(200, response);

const result = await ProtectedApiClient.filterSiteImages(params);

expect(result).toEqual(response);
});

it('makes a bad request', async () => {
const response = 'Invalid dates given!';

const params = {
submittedStart: 'invalid date',
submittedEnd: null,
neighborhoods: [0, 1, 2],
siteIds: [0, 1, 2, 3],
};

nock(BASE_URL)
.get(ParameterizedAdminApiRoutes.FILTER_SITE_IMAGES(params))
.reply(400, response);

const result = await ProtectedApiClient.filterSiteImages(params).catch(
(err) => err.response.data,
);

expect(result).toEqual(response);
});

it('makes an unauthorized request ', async () => {
const response = 'Must be an admin';

const params = {
submittedStart: null,
submittedEnd: null,
neighborhoods: [0, 1, 2],
siteIds: [0, 1, 2, 3],
};

nock(BASE_URL)
.get(ParameterizedAdminApiRoutes.FILTER_SITE_IMAGES(params))
.reply(401, response);

const result = await ProtectedApiClient.filterSiteImages(params).catch(
(err) => err.response.data,
);

expect(result).toEqual(response);
});
});

describe('uploadImage', () => {
let imageToUpload: string | ArrayBuffer;

Expand Down
6 changes: 4 additions & 2 deletions src/components/treePage/treeInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,11 @@ export const TreeInfo: React.FC<TreeProps> = ({
isAdopted={isAdopted}
/>

{/* {treePresent && (
{treePresent && (
<UploadSiteImageButton siteEntryId={siteData.entries[0].id} />
)} */}
)}

<ReportSiteButton siteId={siteData.siteId} />

<ReportSiteButton siteId={siteData.siteId} />

Expand Down
141 changes: 141 additions & 0 deletions src/components/unapprovedFilterImageControls/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import React, { useState, useEffect } from 'react';
import moment from 'moment';
import { EmailerFilters } from '../../containers/email/types';
import {
Collapse,
Slider,
DatePicker,
Select,
message,
SelectProps,
} from 'antd';
import { SliderMarks } from 'antd/lib/slider';
import { Neighborhoods } from '../../assets/content';
import apiClient from '../../api/apiClient';
import { formatActivityCountRange } from '../../utils/stringFormat';
import styled from 'styled-components';
import { ReviewImageFilters } from '../../containers/reviewImages/types';
import { DefaultOptionType } from 'antd/es/select';

const AutoCompleteSelect = styled((props: SelectProps) => (
<Select {...props} />
))`
min-width: 200px;
max-width: 500px;
`;

const MAX_ACTIVITY_COUNT = 10;

// convert emailer filter values to the slider's internal values
function activityCountRange(filters: EmailerFilters): [number, number] {
return [
filters.activityCountMin,
filters.activityCountMax ?? MAX_ACTIVITY_COUNT + 1,
];
}

function formatDates(
start: string | null,
end: string | null,
): [moment.Moment | null, moment.Moment | null] {
return [start ? moment(start) : null, end ? moment(end) : null];
}

function disabledDate(current: moment.Moment): boolean {
// Can not select future days
return current > moment().endOf('day');
}

const neighborhoodOptions = Object.values(Neighborhoods)
.sort()
.map((value) => {
return { label: value, value };
});

interface UnapprovedFilterImageFilterControlsProps {
filters: ReviewImageFilters;
setFilters: React.Dispatch<React.SetStateAction<ReviewImageFilters>>;
}

const UnapprovedFilterImageControls: React.FC<
UnapprovedFilterImageFilterControlsProps
> = ({ filters, setFilters }) => {
const [siteIdOptions, setSiteIdOptions] = useState<
{ label: string; value: string }[]
>([]);

const [siteIdInvalidOrNone, setSiteIdError] = useState<{
status: '' | 'warning' | 'error' | undefined;
value: string;
}>({ status: '', value: 'Enter a site id' });

// TODO: verify what Tree ID means
return (
<Collapse ghost>
<Collapse.Panel header="Date Submitted" key="submittedDate">
<DatePicker.RangePicker
allowEmpty={[true, true]}
value={formatDates(filters.submittedStart, filters.submittedEnd)}
onChange={(_, dateStrings) =>
setFilters({
...filters,
submittedStart: dateStrings[0] || null,
submittedEnd: dateStrings[1] || null,
})
}
disabledDate={disabledDate}
/>
</Collapse.Panel>
<Collapse.Panel header="Neighborhood" key="neighborhood">
<AutoCompleteSelect
value={filters.neighborhoods}
mode="multiple"
allowClear
placeholder="Enter a neighborhood"
onChange={(value: Neighborhoods[]) =>
setFilters({ ...filters, neighborhoods: value })
}
options={neighborhoodOptions}
/>
</Collapse.Panel>
<Collapse.Panel header="Tree ID" key="siteId">
<AutoCompleteSelect
value={filters.siteIds}
mode="multiple"
allowClear
placeholder="Enter a site"
onChange={(value: number[]) => {
let valueInt = value.filter((v) => !isNaN(Number(v)));
valueInt = valueInt.map((v) => Number(v));
setFilters({ ...filters, siteIds: valueInt });
}}
status={siteIdInvalidOrNone.status}
onSearch={(e) => {
const numVal = Number(e);
if (numVal || e.length === 0) {
setSiteIdError({
status: '',
value: '',
});
if (e.length > 0) setSiteIdOptions([{ label: e, value: e }]);
} else if (e === '') {
setSiteIdError({
status: '',
value: 'Enter a site id',
});
} else {
setSiteIdError({
status: 'error',
value: 'No data. Site IDs must be strings',
});
}
}}
options={siteIdOptions}
notFoundContent={siteIdInvalidOrNone.value}
/>
</Collapse.Panel>
</Collapse>
);
};

export default UnapprovedFilterImageControls;
88 changes: 88 additions & 0 deletions src/components/unapprovedImagesTable/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React, { SetStateAction, useMemo, useState } from 'react';
import { Table } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { EmailerTableData, FilteredSite } from '../../containers/email/types';
import { NEIGHBORHOOD_IDS } from '../../assets/content';
import {
FilteredSiteImage,
FilterImageTableData,
} from '../../containers/reviewImages/types';

interface UnapprovedImagesTable {
readonly fetchData: FilteredSiteImage[];
readonly setSelectedImageIds: React.Dispatch<SetStateAction<number[]>>;
}

const columns: ColumnsType<FilterImageTableData> = [
{
title: 'Preview',
dataIndex: 'preview_url',
key: 'preview_url',
render: (dataIndexValue, record) => {
return <img style={{ height: 60, width: 60 }} src={record.preview}></img>;
},
},
{
title: 'Site ID',
dataIndex: 'siteId',
key: 'siteId',
},
{
title: 'Species',
dataIndex: 'species',
key: 'species',
},
{
title: 'Neighborhood',
dataIndex: 'neighborhood',
key: 'neighborhood',
},
{
title: 'Date Submitted',
dataIndex: 'dateSubmitted',
key: 'dateSubmitted',
},
];

function responseToTableData(
data: FilteredSiteImage,
index: number,
): FilterImageTableData {
return {
key: data.imageId,
preview: data.imageUrl,
siteId: data.siteId,
dateSubmitted: data.dateSubmitted,
species: data.commonName,
neighborhood: NEIGHBORHOOD_IDS[data.neighborhoodId],
};
}

const UnapprovedImagesTable: React.FC<UnapprovedImagesTable> = ({
fetchData,
setSelectedImageIds,
}) => {
const tableData = useMemo(
() => (fetchData ? fetchData.map(responseToTableData) : []),
[fetchData],
);

const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);

return (
<Table
columns={columns}
dataSource={tableData}
size="middle"
rowSelection={{
selectedRowKeys,
onChange: (_, selectedRows) => {
setSelectedImageIds(selectedRows.map((row) => row.key));
setSelectedRowKeys(selectedRows.map((row) => row.key));
},
}}
/>
);
};

export default UnapprovedImagesTable;
Loading

0 comments on commit d9182fd

Please sign in to comment.