Skip to content
Open
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 @@ -31,6 +31,7 @@ def show

@assessment = @listing.assessment
authorize!(:preview_in_marketplace, @assessment)
@destination_tabs = destination_tabs
render 'show'
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ json.id @assessment.id
json.title @assessment.title
json.description format_ckeditor_rich_text(@assessment.description)

# The current course's category/tab structure, so the duplicate confirmation dialog can offer the
# destination tab picker from the listing detail page (the listing itself lives in another course).
json.destinationTabs @destination_tabs do |tab|
json.id tab[:id]
json.title tab[:title]
json.categoryId tab[:category_id]
json.categoryTitle tab[:category_title]
end

json.gradingMode @assessment.autograded? ? 'autograded' : 'manual'
json.baseExp @assessment.base_exp if @assessment.base_exp > 0
json.bonusExp @assessment.time_bonus_exp if @assessment.time_bonus_exp > 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,18 @@ const translations: Record<DuplicableItemType, MessageDescriptor> =
},
});

const TypeBadge: FC<{ text?: string; itemType: DuplicableItemType }> = ({
text,
itemType,
}) => {
const TypeBadge: FC<{
text?: string;
itemType: DuplicableItemType;
dense?: boolean;
}> = ({ text, itemType, dense = false }) => {
const { t } = useTranslation();

return (
<Typography
className="px-2 py-1 border border-solid rounded-md mr-3"
className={`px-2 ${
dense ? 'py-0.5' : 'py-1'
} border border-solid rounded-md mr-3`}
fontWeight="inherit"
variant="caption"
>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { FC } from 'react';
import {
Card,
CardContent,
FormControlLabel,
Radio,
RadioGroup,
} from '@mui/material';

import TypeBadge from 'course/duplication/components/TypeBadge';

import { DestinationTab } from '../types';

interface Group {
categoryId: number;
categoryTitle: string;
tabs: DestinationTab[];
}

interface DestinationTabPickerProps {
tabs: DestinationTab[];
value: number | null;
onChange: (tabId: number) => void;
}

// Group tabs by category in first-seen order (the controller already emits categories then their
// tabs in display order, so this preserves that without re-sorting). Robust to a category's tabs
// arriving non-contiguously.
const groupByCategory = (tabs: DestinationTab[]): Group[] => {
const groups: Group[] = [];
const indexByCategory = new Map<number, number>();
tabs.forEach((tab) => {
const existing = indexByCategory.get(tab.categoryId);
if (existing === undefined) {
indexByCategory.set(tab.categoryId, groups.length);
groups.push({
categoryId: tab.categoryId,
categoryTitle: tab.categoryTitle,
tabs: [tab],
});
} else {
groups[existing].tabs.push(tab);
}
});
return groups;
};

const DestinationTabPicker: FC<DestinationTabPickerProps> = ({
tabs,
value,
onChange,
}) => {
const groups = groupByCategory(tabs);

return (
<Card>
<CardContent className="overflow-y-auto" style={{ maxHeight: 320 }}>
<RadioGroup
onChange={(e): void => onChange(Number(e.target.value))}
value={value != null ? String(value) : ''}
>
{groups.map((group) => (
<div
key={`category_${group.categoryId}`}
className="flex flex-col items-start"
>
<div className="flex items-center py-2 text-xl font-bold">
<TypeBadge dense itemType="CATEGORY" />
{group.categoryTitle}
</div>
{group.tabs.map((tab) => (
<FormControlLabel
key={`tab_${tab.id}`}
className="ml-4 text-xl"
control={<Radio className="py-0 px-2" />}
label={
<span className="flex items-center text-xl font-bold">
<TypeBadge dense itemType="TAB" />
{tab.title}
</span>
}
value={String(tab.id)}
/>
))}
</div>
))}
</RadioGroup>
</CardContent>
</Card>
);
};

export default DestinationTabPicker;
Original file line number Diff line number Diff line change
@@ -1,45 +1,91 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Tooltip } from 'react-tooltip';
import { Card, CardContent, ListSubheader } from '@mui/material';

import DuplicationAssessmentTree from 'course/duplication/components/DuplicationAssessmentTree';
import TypeBadge from 'course/duplication/components/TypeBadge';
import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon';
import Prompt from 'lib/components/core/dialogs/Prompt';
import Link from 'lib/components/core/Link';
import toast from 'lib/hooks/toast';
import useTranslation from 'lib/hooks/useTranslation';

import { duplicateListings } from '../operations';
import translations from '../translations';
import { MarketplaceListing } from '../types';
import { DestinationTab, MarketplaceListing } from '../types';

import DestinationTabPicker from './DestinationTabPicker';

interface Props {
listings: Pick<MarketplaceListing, 'id' | 'title'>[];
destinationTabId: number | null;
destinationTabs: DestinationTab[];
initialDestinationTabId: number | null;
destinationCourse: { title: string; url: string };
destinationCategory: { id: number; title: string } | null;
destinationTab: { id: number; title: string } | null;
open: boolean;
onClose: () => void;
}

const DuplicateConfirmation = ({
listings,
destinationTabId,
destinationTabs,
initialDestinationTabId,
destinationCourse,
destinationCategory,
destinationTab,
open,
onClose,
}: Props): JSX.Element => {
const { t } = useTranslation();
const [submitting, setSubmitting] = useState(false);

// The selected tab defaults to the `from_tab` the user launched from, but only when it names a
// real tab in this course; otherwise the course's first tab. So exactly one existing tab is
// always selected, and an unknown/absent from_tab yields null (backend then defaults) rather than
// a phantom id.
const resolveInitial = (): number | null => {
if (
initialDestinationTabId != null &&
destinationTabs.some((tab) => tab.id === initialDestinationTabId)
) {
return initialDestinationTabId;
}
return destinationTabs[0]?.id ?? null;
};

const [selectedTabId, setSelectedTabId] = useState<number | null>(
resolveInitial(),
);

// The pages keep this component mounted and only flip `open`, so `selectedTabId` outlives a close
// — re-seed it each time the dialog opens, or a tab the user picked and then walked away from
// would still be selected next time.
//
// Deps are `[open]` on purpose. Adding `destinationTabs` would compare it by identity, so any
// parent re-render passing a fresh array would re-fire this and reset the radio out from under a
// user mid-decision. Reopening is the only moment the selection should be re-seeded.
useEffect(() => {
if (!open) return;
setSelectedTabId(resolveInitial());
}, [open]);

const confirm = async (): Promise<void> => {
setSubmitting(true);
await duplicateListings(
listings.map((l) => l.id),
destinationTabId,
() => {
toast.success(t(translations.duplicateStarted, { n: listings.length }));
selectedTabId,
// This is pollJob's *completion* callback — the job has finished by now. `redirectUrl` points
// at the destination tab; it is optional on JobCompleted, so the link is conditional.
(redirectUrl) => {
toast.success(
<>
{t(translations.duplicateCompleted, { n: listings.length })}
{redirectUrl && (
<>
{' '}
<Link href={redirectUrl}>
{t(translations.viewDuplicatedAssessment)}
</Link>
</>
)}
</>,
);
setSubmitting(false);
onClose();
},
Expand All @@ -52,10 +98,12 @@ const DuplicateConfirmation = ({

return (
<Prompt
cancelColor="secondary"
disabled={submitting}
onClickPrimary={confirm}
onClose={onClose}
open={open}
primaryColor="primary"
primaryLabel={t(translations.duplicateConfirm)}
title={t(translations.confirmationQuestion)}
>
Expand All @@ -71,16 +119,32 @@ const DuplicateConfirmation = ({
</Card>

<ListSubheader disableSticky>
{t(translations.assessmentsHeading)}
{t(translations.pickDestinationTab)}
</ListSubheader>
<DuplicationAssessmentTree
nodes={[
{
category: destinationCategory,
tabs: [{ tab: destinationTab, assessments: listings }],
},
]}
<DestinationTabPicker
onChange={setSelectedTabId}
tabs={destinationTabs}
value={selectedTabId}
/>

<ListSubheader disableSticky>{t(translations.duplicating)}</ListSubheader>
<Card>
<CardContent>
{listings.map((listing) => (
<div
key={listing.id}
className="flex items-center py-1 text-xl font-bold"
>
<TypeBadge dense itemType="ASSESSMENT" />
<UnpublishedIcon tooltipId="itemUnpublished" />
{listing.title}
</div>
))}
</CardContent>
</Card>
<Tooltip id="itemUnpublished" style={{ fontSize: '1.4rem' }}>
{t(translations.itemUnpublished)}
</Tooltip>
</Prompt>
);
};
Expand Down
Loading