Skip to content

Commit

Permalink
Review page: Display counts of different sequences statuses and allow…
Browse files Browse the repository at this point in the history
… filtering and improve loading CLS and restyle buttons (#1371)
  • Loading branch information
theosanderson authored Mar 22, 2024
1 parent 262ecbe commit a996ba2
Show file tree
Hide file tree
Showing 8 changed files with 179 additions and 61 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ class SubmissionDatabaseService(
val listOfStatuses = statusesFilter ?: Status.entries

sequenceEntriesTableProvider.get(organism).let { table ->
val query = table
val baseQuery = table
.join(
DataUseTermsTable,
JoinType.LEFT,
Expand All @@ -456,30 +456,33 @@ class SubmissionDatabaseService(
)
.select(
where = {
table.statusIsOneOf(listOfStatuses) and
table.groupNameIsOneOf(validatedGroupNames)
table.groupNameIsOneOf(validatedGroupNames)
},
)
.orderBy(table.accessionColumn)

if (organism != null) {
query.andWhere { table.organismIs(organism) }
baseQuery.andWhere { table.organismIs(organism) }
}

val statusCounts: Map<Status, Int> = listOfStatuses.associateWith { status ->
query.count { it[table.statusColumn] == status.name }
val statusCounts: Map<Status, Int> = Status.entries.associateWith { status ->
baseQuery.count { it[table.statusColumn] == status.name }
}

val filteredQuery = baseQuery.andWhere {
table.statusIsOneOf(listOfStatuses)
}

if (warningsFilter == WarningsFilter.EXCLUDE_WARNINGS) {
query.andWhere {
filteredQuery.andWhere {
not(table.entriesWithWarnings)
}
}

val pagedQuery = if (page != null && size != null) {
query.limit(size, (page * size).toLong())
filteredQuery.limit(size, (page * size).toLong())
} else {
query
filteredQuery
}

return GetSequenceResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.containsInAnyOrder
import org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.empty
import org.hamcrest.Matchers.hasEntry
import org.hamcrest.Matchers.hasSize
import org.hamcrest.Matchers.`is`
import org.junit.jupiter.api.Test
Expand Down Expand Up @@ -169,7 +170,7 @@ class GetSequencesEndpointTest(
)

assertThat(resultForInAwaitingApprovalPageOne.sequenceEntries, hasSize(5))
assertThat(resultForInAwaitingApprovalPageOne.statusCounts, `is`(mapOf(AWAITING_APPROVAL to 10)))
assertThat(resultForInAwaitingApprovalPageOne.statusCounts, hasEntry(AWAITING_APPROVAL, 10))

val resultForInAwaitingApprovalPageTwo = convenienceClient.getSequenceEntries(
statusesFilter = listOf(AWAITING_APPROVAL),
Expand Down
2 changes: 1 addition & 1 deletion website/src/components/ReviewPage/ReviewCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export const ReviewCard: FC<ReviewCardProps> = ({
const { isLoading, data } = useGetMetadataAndAnnotations(organism, clientConfig, accessToken, sequenceEntryStatus);

return (
<div className='p-3 border rounded-md shadow-lg relative transition-all duration-500'>
<div className='px-3 py-2 relative transition-all duration-500'>
<div className='flex'>
<div className='flex flex-grow flex-wrap '>
<StatusIcon
Expand Down
6 changes: 3 additions & 3 deletions website/src/components/ReviewPage/ReviewPage.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ describe('ReviewPage', () => {
const { getByText } = renderReviewPage();

await waitFor(() => {
expect(getByText('No sequences to review')).toBeDefined();
expect(getByText('You do not currently have any unreleased sequences awaiting review.')).toBeDefined();
});
});

Expand Down Expand Up @@ -132,7 +132,7 @@ describe('ReviewPage', () => {
getByText((text) => text.includes('Confirm')).click();

await waitFor(() => {
expect(getByText('No sequences to review')).toBeDefined();
expect(getByText('You do not currently have any unreleased sequences awaiting review.')).toBeDefined();
});
});

Expand All @@ -151,7 +151,7 @@ describe('ReviewPage', () => {
const { getByText } = renderReviewPage();

await waitFor(() => {
expect(getByText((text) => text.includes('2 of 4 sequences processed.'))).toBeDefined();
expect(getByText((text) => text.includes('2 of 4 sequences processed'))).toBeDefined();
});
});
});
186 changes: 146 additions & 40 deletions website/src/components/ReviewPage/ReviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,21 @@ import {
awaitingApprovalForRevocationStatus,
type PageQuery,
type SequenceEntryStatus,
type GetSequencesResponse,
receivedStatus,
} from '../../types/backend.ts';
import { type ClientConfig } from '../../types/runtimeConfig.ts';
import { displayConfirmationDialog } from '../ConfirmationDialog.tsx';
import { ManagedErrorFeedback, useErrorFeedbackState } from '../common/ManagedErrorFeedback.tsx';
import { withQueryProvider } from '../common/withQueryProvider.tsx';
import BiTrash from '~icons/bi/trash';
import IwwaArrowDown from '~icons/iwwa/arrow-down';
import LucideFilter from '~icons/lucide/filter';
import WpfPaperPlane from '~icons/wpf/paper-plane';
const menuItemClassName = `group flex rounded-md items-center w-full px-2 py-2 text-sm
hover:bg-gray-400 bg-gray-500 text-white text-left mb-1`;
hover:bg-primary-500 bg-primary-600 text-white text-left mb-1`;

let oldSequenceData: GetSequencesResponse | null = null;

type ReviewPageProps = {
clientConfig: ClientConfig;
Expand All @@ -34,58 +39,150 @@ type ReviewPageProps = {

const pageSizeOptions = [10, 20, 50, 100] as const;

const NumberAndVisibility = ({
text,
countNumber,
setVisibility,
visibilityEnabled,
}: {
text: string;
countNumber: number;
setVisibility: (value: boolean) => void;
visibilityEnabled: boolean;
}) => {
return (
<div className='flex items-center gap-3 text-sm text-gray-600'>
<label>
<input
type='checkbox'
checked={visibilityEnabled}
onChange={() => setVisibility(visibilityEnabled === false)}
className='mr-2 text-gray-400'
/>
<span className=' inline-block font-semibold '>{countNumber} </span>&nbsp;
{text}
</label>
</div>
);
};

const InnerReviewPage: FC<ReviewPageProps> = ({ clientConfig, organism, accessToken }) => {
const { errorMessage, isErrorOpen, openErrorFeedback, closeErrorFeedback } = useErrorFeedbackState();
const [showErrors, setShowErrors] = useState(true);

const [pageQuery, setPageQuery] = useState<PageQuery>({ page: 1, size: pageSizeOptions[2] });

const hooks = useSubmissionOperations(organism, clientConfig, accessToken, openErrorFeedback, pageQuery);

const showErrors = hooks.includedStatuses.includes(hasErrorsStatus);
const showUnprocessed =
hooks.includedStatuses.includes(inProcessingStatus) && hooks.includedStatuses.includes(receivedStatus);
const showValid =
hooks.includedStatuses.includes(awaitingApprovalStatus) &&
hooks.includedStatuses.includes(awaitingApprovalForRevocationStatus);

const setAStatus = (status: string, value: boolean) => {
hooks.setIncludedStatuses((prev) => {
if (value) {
return [...prev, status];
}
return prev.filter((s) => s !== status);
});
};

const setShowErrors = (value: boolean) => setAStatus(hasErrorsStatus, value);
const setShowUnprocessed = (value: boolean) => {
setAStatus(inProcessingStatus, value);
setAStatus(receivedStatus, value);
};

const setShowValid = (value: boolean) => {
setAStatus(awaitingApprovalStatus, value);
setAStatus(awaitingApprovalForRevocationStatus, value);
};

const handleSizeChange = (event: ChangeEvent<HTMLSelectElement>) => {
const newSize = parseInt(event.target.value, 10);
setPageQuery({ page: 1, size: newSize });
};

let sequencesData = hooks.getSequences.data;

if (!hooks.getSequences.isLoading && !hooks.getSequences.isError) {
oldSequenceData = hooks.getSequences.data;
}

if (hooks.getSequences.isLoading) {
return <div>Loading...</div>;
if (oldSequenceData) {
sequencesData = oldSequenceData;
} else {
return <div>Loading...</div>;
}
}

if (hooks.getSequences.isError) {
return <div>Error: {hooks.getSequences.error.message}</div>;
}

if (hooks.getSequences.data.sequenceEntries.length === 0) {
return <div>No sequences to review</div>;
if (sequencesData === undefined) {
return <div>Loading..</div>;
// this is not expected to happen, but it's here to satisfy the type checker
}

const total = Object.values(hooks.getSequences.data.statusCounts).reduce(
(acc: number, count: number) => acc + count,
0,
);
const processingCount = hooks.getSequences.data.statusCounts[inProcessingStatus];
const processedCount = hooks.getSequences.data.statusCounts[awaitingApprovalStatus];
const errorCount = hooks.getSequences.data.statusCounts[hasErrorsStatus];
const revocationCount = hooks.getSequences.data.statusCounts[awaitingApprovalForRevocationStatus];
const processingCount = sequencesData.statusCounts[inProcessingStatus];
const processedCount = sequencesData.statusCounts[awaitingApprovalStatus];
const errorCount = sequencesData.statusCounts[hasErrorsStatus];
const revocationCount = sequencesData.statusCounts[awaitingApprovalForRevocationStatus];
const receivedCount = sequencesData.statusCounts[receivedStatus];

const finishedCount = processedCount + errorCount + revocationCount;
const unfinishedCount = receivedCount + processingCount;

const total = finishedCount + unfinishedCount;
const validCount = processedCount + revocationCount;

if (total === 0) {
return (
<div className='pt-1 text-gray-600'>
You do not currently have any unreleased sequences awaiting review.
</div>
);
}

const sequences: SequenceEntryStatus[] = hooks.getSequences.data.sequenceEntries;
const categoryInfo = [
{
text: 'sequences still awaiting processing',
countNumber: unfinishedCount,
setVisibility: setShowUnprocessed,
visibilityEnabled: showUnprocessed,
},
{
text: 'valid sequences',
countNumber: validCount,
setVisibility: setShowValid,
visibilityEnabled: showValid,
},
{
text: 'sequences with errors',
countNumber: errorCount,
setVisibility: setShowErrors,
visibilityEnabled: showErrors,
},
];

const sequences: SequenceEntryStatus[] = sequencesData.sequenceEntries;

const controlPanel = (
<div className='flex flex-col py-2'>
<div>
{finishedCount} of {total} sequences processed.
{processingCount > 0 && <span className='loading loading-spinner loading-sm ml-3'> </span>}
<div className='flex flex-col'>
<div className='text-gray-600 mr-3'>
{unfinishedCount > 0 && (
<span className='loading loading-spinner loading-sm mr-2 relative top-1'> </span>
)}
{finishedCount} of {total} sequences processed
</div>
<div>
<input
className='mr-3'
type='checkbox'
checked={showErrors}
title='Show sequences with errors'
onChange={(e) => setShowErrors(e.target.checked)}
/>
Also show entries with errors
<div className='border border-gray-200 rounded-md p-3 mt-3 flex gap-3'>
<LucideFilter className='w-4 h-4 mr-1.5 inline-block text-gray-500 mt-0.5' />
{categoryInfo.map((info) => {
return <NumberAndVisibility {...info} />;
})}
</div>
</div>
);
Expand Down Expand Up @@ -116,10 +213,10 @@ const InnerReviewPage: FC<ReviewPageProps> = ({ clientConfig, organism, accessTo
);

const bulkActionButtons = (
<div className='flex justify-end items-center gap-3'>
<div className='flex justify-end items-center gap-3 mt-auto '>
{finishedCount > 0 && (
<Menu as='div' className='relative inline-block text-left'>
<Menu.Button className='border rounded-md p-1 bg-gray-500 text-white px-2'>
<Menu as='div' className=' inline-block text-left'>
<Menu.Button className='border rounded-md p-1 bg-primary-600 text-white px-2'>
<BiTrash className='inline-block w-4 h-4 -mt-0.5 mr-1.5' />
Discard sequences
<IwwaArrowDown className='inline-block ml-1 w-3 h-3 -mt-0.5' />
Expand Down Expand Up @@ -171,7 +268,7 @@ const InnerReviewPage: FC<ReviewPageProps> = ({ clientConfig, organism, accessTo
)}
{processedCount + revocationCount > 0 && (
<button
className='border rounded-md p-1 bg-gray-500 text-white px-2'
className='border rounded-md p-1 bg-primary-600 text-white px-2'
onClick={() =>
displayConfirmationDialog({
dialogText: 'Are you sure you want to release all valid sequences?',
Expand All @@ -191,11 +288,8 @@ const InnerReviewPage: FC<ReviewPageProps> = ({ clientConfig, organism, accessTo
);

const reviewCards = (
<div className='flex flex-col gap-2 py-4'>
<div className='flex flex-col gap-2 py-4 divide-y divide-gray-200'>
{sequences.map((sequence) => {
if (!showErrors && sequence.status === hasErrorsStatus) {
return null;
}
return (
<div key={sequence.accession}>
<ReviewCard
Expand Down Expand Up @@ -228,10 +322,22 @@ const InnerReviewPage: FC<ReviewPageProps> = ({ clientConfig, organism, accessTo
return (
<>
<ManagedErrorFeedback message={errorMessage} open={isErrorOpen} onClose={closeErrorFeedback} />
{controlPanel}
{bulkActionButtons}
{reviewCards}
{pagination}
<div className={hooks.getSequences.isLoading ? 'opacity-50 pointer-events-none' : ''}>
<div className='sticky top-0 z-10'>
<div className='flex sm:justify-between items-bottom flex-col md:flex-row gap-5 bg-white pb-1'>
{controlPanel}
{bulkActionButtons}
</div>
<div
className='h-2 w-full'
style={{
background: 'linear-gradient(0deg, rgba(255, 255, 255, 0) 0%,rgba(100, 100, 100, .2) 80%)',
}}
></div>
</div>
{reviewCards}
{pagination}
</div>
</>
);
};
Expand Down
Loading

0 comments on commit a996ba2

Please sign in to comment.