Skip to content

Commit c7d97d6

Browse files
Alex-Jordanclaude
andcommitted
buttons for reordering problems in a set details page
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 9ed437c commit c7d97d6

2 files changed

Lines changed: 335 additions & 123 deletions

File tree

htdocs/js/ProblemSetDetail/problemsetdetail.js

Lines changed: 189 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,192 @@
4242
}
4343
};
4444

45+
const getSourceFilePath = (id) =>
46+
document.getElementById(`problem.${id}.source_file_id`)?.value ||
47+
document.getElementById(`problem_${id}_default_source_file`)?.value ||
48+
'';
49+
50+
// Compute the "same source file" alerts for every problem based on the current order of the list.
51+
const updateRepeatFileAlerts = () => {
52+
if (!container) return;
53+
const repeatFileText = container.dataset.repeatFileText;
54+
if (!repeatFileText) return;
55+
56+
const shownYet = new Map();
57+
for (const item of container.querySelectorAll('.psd_list_item')) {
58+
const row = item.querySelector(':scope > .problem_detail_row');
59+
const alertContainer = row?.querySelector('.pdr_repeat_file_alert');
60+
if (!alertContainer) return;
61+
62+
alertContainer.innerHTML = '';
63+
64+
const problemID = item.id.replace('psd_list_item_', '');
65+
const sourceFile = getSourceFilePath(problemID).replace(/^\//, '').replace(/\.\./g, '');
66+
if (!sourceFile || sourceFile.startsWith('group:')) return;
67+
68+
if (shownYet.has(sourceFile)) {
69+
const alert = document.createElement('div');
70+
alert.className = 'alert alert-danger p-1 mb-2 fw-bold';
71+
alert.textContent = repeatFileText.replace('[_1]', shownYet.get(sourceFile));
72+
alertContainer.append(alert);
73+
} else {
74+
shownYet.set(sourceFile, row.querySelector('.pdr_problem_number')?.textContent ?? '');
75+
}
76+
}
77+
};
78+
4579
const setProblemNumberFields = () => {
4680
container.querySelectorAll('.psd_list_item .pdr_problem_number').forEach((num) => (num.textContent = ''));
4781
recursiveRenumber(Sortable.get(container).toArray());
82+
updateRepeatFileAlerts();
83+
};
84+
85+
// Buttons to rearrange problems. Either up/down for rearranging order, or buttons for nestin/de-nesting JITAR
86+
// problems. Buttons are disabled when the corresponding action isn't possible.
87+
const updateReorderButtonStates = () => {
88+
if (!container) return;
89+
for (const list of [container, ...container.querySelectorAll('.sortable-branch')]) {
90+
const items = list.querySelectorAll(':scope > .psd_list_item');
91+
for (const [index, item] of items.entries()) {
92+
const upButton = item.querySelector(':scope > .problem_detail_row .psd_move_up');
93+
const downButton = item.querySelector(':scope > .problem_detail_row .psd_move_down');
94+
if (upButton) upButton.disabled = index === 0;
95+
if (downButton) downButton.disabled = index === items.length - 1;
96+
97+
// Nesting nests the item under its previous sibling, so it needs a previous sibling to nest under.
98+
// Denesting moves the item up to its parent's list, so it isn't possible at the top level.
99+
const nestButton = item.querySelector(':scope > .problem_detail_row .psd_nest');
100+
const denestButton = item.querySelector(':scope > .problem_detail_row .psd_denest');
101+
if (nestButton) nestButton.disabled = index === 0;
102+
if (denestButton) denestButton.disabled = list === container;
103+
}
104+
}
105+
};
106+
107+
// Recompute the nestDepth property (used by the drag-and-drop "put" rule) for a sub-list and all of its
108+
// descendant sub-lists after the sub-list has moved to a new position in the tree.
109+
const recomputeNestDepth = (list) => {
110+
if (!list) return;
111+
list.nestDepth = list.parentNode.closest('.sortable-branch').nestDepth + 1;
112+
list.querySelectorAll(':scope > .psd_list_item > .sortable-branch').forEach(recomputeNestDepth);
113+
};
114+
115+
// Show or hide each row's expand/collapse button depending on whether its sub-list currently has any children.
116+
const updateCollapseButtonVisibility = () => {
117+
if (!container) return;
118+
for (const list of container.querySelectorAll('.sortable-branch')) {
119+
if (list.firstElementChild) list.parentNode.collapseButton?.classList.remove('d-none');
120+
else list.parentNode.collapseButton?.classList.add('d-none');
121+
}
122+
};
123+
124+
// Bookkeeping after any keyboard-driven reorder/nest/denest: update the hidden problem number/parent fields,
125+
// re-enable/disable fields whose relevance depends on tree position, update this row's button states, and
126+
// manage focus.
127+
const finishReorder = (item, button, fallbackSelectors) => {
128+
setProblemNumberFields();
129+
disableFields();
130+
updateReorderButtonStates();
131+
updateCollapseButtonVisibility();
132+
133+
if (!button.disabled) {
134+
button.focus();
135+
return;
136+
}
137+
for (const selector of fallbackSelectors) {
138+
const fallback = item.querySelector(selector);
139+
if (fallback && !fallback.disabled) {
140+
fallback.focus();
141+
return;
142+
}
143+
}
144+
};
145+
146+
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
147+
148+
// Animate elements that just moved from where they used to be to where they ended up (the "FLIP" technique),
149+
// so a move up/down reads as the two rows swapping places instead of an unexplained instant jump. Only the
150+
// vertical position can change here, since these are stacked list items.
151+
const animateReorder = (elementsAndFirstRects) => {
152+
if (prefersReducedMotion) return;
153+
for (const [el, firstRect] of elementsAndFirstRects) {
154+
const deltaY = firstRect.top - el.getBoundingClientRect().top;
155+
if (!deltaY) continue;
156+
el.style.transition = 'none';
157+
el.style.transform = `translateY(${deltaY}px)`;
158+
el.getBoundingClientRect(); // Force a reflow so the starting position above is rendered before animating.
159+
requestAnimationFrame(() => {
160+
el.style.transition = 'transform 150ms ease-in-out';
161+
el.style.transform = '';
162+
});
163+
el.addEventListener('transitionend', () => (el.style.transition = ''), { once: true });
164+
}
48165
};
49166

167+
const moveItem = (button, direction) => {
168+
const item = button.closest('.psd_list_item');
169+
const sibling = direction === 'up' ? item.previousElementSibling : item.nextElementSibling;
170+
if (!sibling?.classList.contains('psd_list_item')) return;
171+
172+
const firstRects = [item, sibling].map((el) => [el, el.getBoundingClientRect()]);
173+
174+
if (direction === 'up') item.parentNode.insertBefore(item, sibling);
175+
else item.parentNode.insertBefore(sibling, item);
176+
177+
animateReorder(firstRects);
178+
179+
finishReorder(item, button, [
180+
direction === 'up' ? '.psd_move_down' : '.psd_move_up',
181+
'.psd_denest',
182+
'.psd_nest'
183+
]);
184+
};
185+
186+
const nestItem = (button) => {
187+
const item = button.closest('.psd_list_item');
188+
const prevSibling = item.previousElementSibling;
189+
if (!prevSibling?.classList.contains('psd_list_item')) return;
190+
191+
const targetList = prevSibling.querySelector(':scope > .sortable-branch');
192+
if (!targetList) return;
193+
194+
targetList.append(item);
195+
recomputeNestDepth(item.querySelector(':scope > .sortable-branch'));
196+
197+
// Make sure the item's new position is actually visible, in case its new parent's children were collapsed.
198+
bootstrap.Collapse.getInstance(targetList)?.show();
199+
200+
finishReorder(item, button, ['.psd_denest', '.psd_move_up', '.psd_move_down']);
201+
};
202+
203+
const denestItem = (button) => {
204+
const item = button.closest('.psd_list_item');
205+
const currentList = item.parentNode;
206+
if (currentList === container) return;
207+
208+
const parentItem = currentList.closest('.psd_list_item');
209+
const grandParentList = parentItem.parentNode;
210+
211+
grandParentList.insertBefore(item, parentItem.nextSibling);
212+
recomputeNestDepth(item.querySelector(':scope > .sortable-branch'));
213+
214+
finishReorder(item, button, ['.psd_nest', '.psd_move_up', '.psd_move_down']);
215+
};
216+
217+
container?.addEventListener('click', (e) => {
218+
const button = e.target.closest('.psd_move_up, .psd_move_down, .psd_nest, .psd_denest');
219+
if (!button || button.disabled) return;
220+
221+
// Since focus stays on (or moves to another) reorder button after the click is handled below, the
222+
// tooltip's own focus/hover triggers won't naturally hide it. Hide it explicitly so it doesn't get stuck.
223+
bootstrap.Tooltip.getInstance(button)?.hide();
224+
225+
if (button.classList.contains('psd_move_up')) moveItem(button, 'up');
226+
else if (button.classList.contains('psd_move_down')) moveItem(button, 'down');
227+
else if (button.classList.contains('psd_nest')) nestItem(button);
228+
else denestItem(button);
229+
});
230+
50231
const setSortable = (list) => {
51232
// Set up the bootstrap collapses. Note that if a list is empty, then the collapse is shown. Since it is empty
52233
// you still see nothing, but dragging into the list is smoother because it doesn't need to be expanded first.
@@ -112,6 +293,7 @@
112293
onSort() {
113294
setProblemNumberFields();
114295
disableFields();
296+
updateReorderButtonStates();
115297
},
116298
onRemove() {
117299
if (hiddenCollapse?._isTransitioning)
@@ -121,10 +303,7 @@
121303
else hiddenCollapse?.show();
122304
},
123305
onChange(evt) {
124-
container.querySelectorAll('.sortable-branch').forEach((list) => {
125-
if (list.firstElementChild) list.parentNode.collapseButton?.classList.remove('d-none');
126-
else list.parentNode.collapseButton?.classList.add('d-none');
127-
});
306+
updateCollapseButtonVisibility();
128307
if (evt.from.querySelectorAll('.psd_list_item').length < 2)
129308
evt.from.parentNode.collapseButton?.classList.add('d-none');
130309
}
@@ -182,6 +361,8 @@
182361

183362
if (container) setSortable(container);
184363
container?.querySelectorAll('.sortable-branch').forEach(setSortable);
364+
updateReorderButtonStates();
365+
updateRepeatFileAlerts();
185366

186367
// Recursively convert the sortable list to a tree. Each entry of the tree has the problem id, the parent id if it
187368
// has a parent, and a list of the child problems (each of which is again an entry of the tree).
@@ -211,7 +392,9 @@
211392

212393
// Initialize tooltips.
213394
document
214-
.querySelectorAll('.psd_view,.psd_edit,.pdr_render,.pdr_grader,.pdr_handle > i')
395+
.querySelectorAll(
396+
'.psd_view,.psd_edit,.pdr_render,.pdr_grader,.pdr_handle > i,.psd_move_up,.psd_move_down,.psd_nest,.psd_denest'
397+
)
215398
.forEach((el) => new bootstrap.Tooltip(el));
216399

217400
// If editing for user(s), then disable drag and drop re-ordering of problems.
@@ -345,9 +528,7 @@
345528

346529
const ro = {
347530
problemSeed: document.getElementById(`problem.${id}.problem_seed_id`)?.value ?? 1,
348-
sourceFilePath:
349-
document.getElementById(`problem.${id}.source_file_id`)?.value ||
350-
document.getElementById(`problem_${id}_default_source_file`)?.value
531+
sourceFilePath: getSourceFilePath(id)
351532
};
352533

353534
if (ro.sourceFilePath.startsWith('group')) {

0 commit comments

Comments
 (0)