Skip to content
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
4 changes: 4 additions & 0 deletions frontend/src/components/dashboard/CodeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export const CodeViewContainer = styled.div`

border: var(--foreground-color) 1px solid;
border-radius: var(--border-radius);

display: flex;
justify-content: center;
align-items: center;
`;

const CodeViewWrapper = styled(CodeViewContainer)`
Expand Down
299 changes: 299 additions & 0 deletions frontend/src/components/dashboard/Showcase.tsx
Copy link
Contributor

Choose a reason for hiding this comment

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

This file feels like it should probably be split across multiple files, adding this to the list of things to revisit after the deadline :-)

Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
import {
createListCollection,
Grid,
GridItem,
HStack,
Icon,
Listbox,
} from "@chakra-ui/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { FormattedMessage } from "react-intl";
import { LuPlus } from "react-icons/lu";
import { useRouter } from "next/router";
import SortableListInput from "../form/SortableList";
import Button from "../Button";
import SwrContent from "../SwrContent";
import { StudentName } from "../encryption/StudentName";
import CodeView, { CodeViewContainer } from "./CodeView";
import { ExistingSessionExtended } from "@/api/collimator/models/sessions/existing-session-extended";
import { ExistingTask } from "@/api/collimator/models/tasks/existing-task";
import { useCurrentSessionTaskSolutions } from "@/api/collimator/hooks/solutions/useCurrentSessionTaskSolutions";
import { ExistingClassExtended } from "@/api/collimator/models/classes/existing-class-extended";
import { CurrentAnalysis } from "@/api/collimator/models/solutions/current-analysis";
import { CurrentStudentAnalysis } from "@/api/collimator/models/solutions/current-student-analysis";
import { ReferenceAnalysis } from "@/api/collimator/models/solutions/reference-analysis";
import { useShowcaseOrder } from "@/hooks/useShowcaseOrder";

type CurrentAnalysisWithId = {
id: string;
value: string;
analysis: CurrentAnalysis;
index: number;
};

const ShowcaseInternal = ({
klass,
session,
task,
analyses,
}: {
klass: ExistingClassExtended;
session: ExistingSessionExtended;
task: ExistingTask;
analyses: CurrentAnalysis[];
}) => {
const router = useRouter();
const [items, setItems] = useState<CurrentAnalysisWithId[]>([]);
const [selectedSolutionIds, setSelectedSolutionIds] = useState<string[]>([]);
const selectedSolutionHash = useMemo(
() =>
selectedSolutionIds.length === 0
? null
: analyses.find((a) => a.solutionId === selectedSolutionIds[0])
?.solutionHash || null,
[analyses, selectedSolutionIds],
);

const listCollection = useMemo(
() =>
createListCollection({
items: items.map((item, index) => ({
...item,
index,
})),
}),
[items],
);

const [showcaseOrder, setShowcaseOrder] = useShowcaseOrder(task.id);

useEffect(() => {
const maxStudentSolutionIndex = analyses.reduce((maxIndex, analysis) => {
if (!(analysis instanceof CurrentStudentAnalysis)) {
return maxIndex;
}

const storedIndex = showcaseOrder[analysis.solutionId];

return storedIndex !== undefined
? Math.max(maxIndex, storedIndex)
: Math.max(maxIndex, 0);
}, 0);

setItems(
analyses
.filter((a) => a.isReferenceSolution)
.map(
(analysis) =>
({
id: analysis.solutionId,
value: analysis.solutionId,
analysis,
index:
// Use stored order,
showcaseOrder[analysis.solutionId] ??
// put reference solutions last
(analysis instanceof ReferenceAnalysis
? Number.MAX_SAFE_INTEGER
: // and new student solutions after all existing ones
maxStudentSolutionIndex + 1),
}) satisfies CurrentAnalysisWithId,
)
.toSorted((a, b) => a.index - b.index),
);
Copy link

Choose a reason for hiding this comment

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

Bug: Showcase filter excludes student solutions

The code filters analyses to only include reference solutions via .filter((a) => a.isReferenceSolution), but the feature should display both student and reference solutions. This means student solutions won't appear in the showcase list, breaking the drag-and-drop functionality described in the PR.

Fix in Cursor Fix in Web

}, [showcaseOrder, analyses]);

// Update stored order when items change
const updateOrder = useCallback(
(newItemOrder: CurrentAnalysisWithId[]) => {
const lastNonReferenceIndex = newItemOrder.findLastIndex(
(a) => a.analysis instanceof CurrentStudentAnalysis,
);

const numberOfReferenceSolutions =
lastNonReferenceIndex === -1
? newItemOrder.length
: newItemOrder.length - lastNonReferenceIndex;

const referenceSolutionIndexStart =
Number.MAX_SAFE_INTEGER - numberOfReferenceSolutions + 1;

setShowcaseOrder(
Object.fromEntries(
newItemOrder.map((item, index) => [
item.analysis.solutionId,
// Everything before the last student analysis gets its current index
index <= lastNonReferenceIndex
? index
: // and reference solutions at the end get last safe integer values
referenceSolutionIndexStart + (index - lastNonReferenceIndex),
]),
),
);
},
[setShowcaseOrder],
);

const solutionIdsToPresent = useMemo(
() => encodeURIComponent(JSON.stringify(items.map((item) => item.id))),
[items],
);

return (
<Grid templateColumns="repeat(12, 1fr)" gap="md" marginBottom="md">
<GridItem
colSpan={12}
display="flex"
justifyContent="center"
marginTop="lg"
marginBottom="lg"
>
<FormattedMessage
id="Showcase.instructions"
defaultMessage="Sort and preview the showcased solutions."
/>
</GridItem>
<GridItem colSpan={{ base: 12, md: 3 }}>
<Listbox.Root
collection={listCollection}
value={selectedSolutionIds}
onValueChange={(details) => setSelectedSolutionIds(details.value)}
variant="subtle"
>
<Listbox.Content padding="0" borderRadius="0" border="0">
<SortableListInput
items={listCollection.items}
updateItems={(items) => {
setItems(items);
updateOrder(items);
}}
noGap
>
{(item) => {
const name =
item.analysis instanceof CurrentStudentAnalysis ? (
<StudentName
studentId={item.analysis.studentId}
keyPairId={item.analysis.studentKeyPairId}
pseudonym={item.analysis.studentPseudonym}
/>
) : item.analysis instanceof ReferenceAnalysis ? (
item.analysis.title
) : (
<FormattedMessage
id="Showcase.unknownAnalysisType"
defaultMessage="Unknown analysis type"
/>
);

return (
<Listbox.Item
item={item}
key={item.value}
padding="md"
borderRadius="0"
backgroundColor={{
base: "gray.100",
_selected: "gray.300",
}}
>
<Listbox.ItemText fontWeight="semiBold">
{item.index + 1}. {name}
</Listbox.ItemText>
<Listbox.ItemIndicator />
</Listbox.Item>
);
}}
</SortableListInput>
</Listbox.Content>
</Listbox.Root>
<Button
marginTop="1rem"
onClick={() =>
router.push(
`/class/${klass.id}/session/${session.id}/task/${task.id}/progress`,
)
}
>
<HStack>
<Icon>
<LuPlus />
</Icon>
<FormattedMessage
id="Showcase.addResult"
defaultMessage="Add Result to Showcase"
/>
</HStack>
</Button>
</GridItem>
<GridItem colSpan={{ base: 12, md: 9 }}>
{selectedSolutionHash ? (
<CodeView
classId={klass.id}
sessionId={session.id}
taskId={task.id}
taskType={task.type}
solutionHash={selectedSolutionHash}
/>
) : (
<CodeViewContainer>
<FormattedMessage
id="Showcase.noStudentSelectedHelp"
defaultMessage="Click on a student name to view their solution."
/>
</CodeViewContainer>
)}
</GridItem>
<GridItem colSpan={12} display="flex" justifyContent="flex-end">
<Button
marginTop="1rem"
onClick={() =>
router.push(
`/class/${klass.id}/session/${session.id}/task/${task.id}/showcase/present?selected=${solutionIdsToPresent}`,
)
}
>
<FormattedMessage
id="Showcase.openShowCasePage"
defaultMessage="Open Showcase Page"
/>
</Button>
</GridItem>
</Grid>
);
};

const Showcase = ({
klass,
session,
task,
}: {
klass: ExistingClassExtended;
session: ExistingSessionExtended;
task: ExistingTask;
}) => {
const {
data: analyses,
isLoading: isLoadingAnalyses,
error: analysesErrors,
} = useCurrentSessionTaskSolutions(session.klass.id, session.id, task.id);

return (
<SwrContent
data={analyses}
isLoading={isLoadingAnalyses}
error={analysesErrors}
>
{(analyses) => (
<ShowcaseInternal
klass={klass}
session={session}
task={task}
analyses={analyses}
/>
)}
</SwrContent>
);
};

export default Showcase;
Loading
Loading