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

Review page: Display counts of different sequences statuses and allow filtering and improve loading CLS and restyle buttons #1371

Merged
merged 9 commits into from
Mar 22, 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
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
Loading