Refactor sidebar assignee&milestone&project selectors (#32465)
Follow #32460 Now the code could be much clearer than before and easier to maintain. A lot of legacy code is removed. Manually tested. This PR is large enough, that fine tunes could be deferred to the future if there is no bug found or design problem. Screenshots: <details>  </details>
This commit is contained in:
parent
58c634b854
commit
a928739456
23 changed files with 503 additions and 828 deletions
|
@ -3,7 +3,7 @@ import {POST} from '../modules/fetch.ts';
|
|||
import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
|
||||
|
||||
// if there are draft comments, confirm before reloading, to avoid losing comments
|
||||
export function issueSidebarReloadConfirmDraftComment() {
|
||||
function issueSidebarReloadConfirmDraftComment() {
|
||||
const commentTextareas = [
|
||||
document.querySelector<HTMLTextAreaElement>('.edit-content-zone:not(.tw-hidden) textarea'),
|
||||
document.querySelector<HTMLTextAreaElement>('#comment-form textarea'),
|
||||
|
@ -22,84 +22,138 @@ export function issueSidebarReloadConfirmDraftComment() {
|
|||
window.location.reload();
|
||||
}
|
||||
|
||||
function collectCheckedValues(elDropdown: HTMLElement) {
|
||||
return Array.from(elDropdown.querySelectorAll('.menu > .item.checked'), (el) => el.getAttribute('data-value'));
|
||||
}
|
||||
class IssueSidebarComboList {
|
||||
updateUrl: string;
|
||||
updateAlgo: string;
|
||||
selectionMode: string;
|
||||
elDropdown: HTMLElement;
|
||||
elList: HTMLElement;
|
||||
elComboValue: HTMLInputElement;
|
||||
initialValues: string[];
|
||||
|
||||
export function initIssueSidebarComboList(container: HTMLElement) {
|
||||
const updateUrl = container.getAttribute('data-update-url');
|
||||
const elDropdown = container.querySelector<HTMLElement>(':scope > .ui.dropdown');
|
||||
const elList = container.querySelector<HTMLElement>(':scope > .ui.list');
|
||||
const elComboValue = container.querySelector<HTMLInputElement>(':scope > .combo-value');
|
||||
let initialValues = collectCheckedValues(elDropdown);
|
||||
constructor(private container: HTMLElement) {
|
||||
this.updateUrl = this.container.getAttribute('data-update-url');
|
||||
this.updateAlgo = container.getAttribute('data-update-algo');
|
||||
this.selectionMode = container.getAttribute('data-selection-mode');
|
||||
if (!['single', 'multiple'].includes(this.selectionMode)) throw new Error(`Invalid data-update-on: ${this.selectionMode}`);
|
||||
if (!['diff', 'all'].includes(this.updateAlgo)) throw new Error(`Invalid data-update-algo: ${this.updateAlgo}`);
|
||||
this.elDropdown = container.querySelector<HTMLElement>(':scope > .ui.dropdown');
|
||||
this.elList = container.querySelector<HTMLElement>(':scope > .ui.list');
|
||||
this.elComboValue = container.querySelector<HTMLInputElement>(':scope > .combo-value');
|
||||
}
|
||||
|
||||
elDropdown.addEventListener('click', (e) => {
|
||||
collectCheckedValues() {
|
||||
return Array.from(this.elDropdown.querySelectorAll('.menu > .item.checked'), (el) => el.getAttribute('data-value'));
|
||||
}
|
||||
|
||||
updateUiList(changedValues) {
|
||||
const elEmptyTip = this.elList.querySelector('.item.empty-list');
|
||||
queryElemChildren(this.elList, '.item:not(.empty-list)', (el) => el.remove());
|
||||
for (const value of changedValues) {
|
||||
const el = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
|
||||
if (!el) continue;
|
||||
const listItem = el.cloneNode(true) as HTMLElement;
|
||||
queryElems(listItem, '.item-check-mark, .item-secondary-info', (el) => el.remove());
|
||||
this.elList.append(listItem);
|
||||
}
|
||||
const hasItems = Boolean(this.elList.querySelector('.item:not(.empty-list)'));
|
||||
toggleElem(elEmptyTip, !hasItems);
|
||||
}
|
||||
|
||||
async updateToBackend(changedValues) {
|
||||
if (this.updateAlgo === 'diff') {
|
||||
for (const value of this.initialValues) {
|
||||
if (!changedValues.includes(value)) {
|
||||
await POST(this.updateUrl, {data: new URLSearchParams({action: 'detach', id: value})});
|
||||
}
|
||||
}
|
||||
for (const value of changedValues) {
|
||||
if (!this.initialValues.includes(value)) {
|
||||
await POST(this.updateUrl, {data: new URLSearchParams({action: 'attach', id: value})});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await POST(this.updateUrl, {data: new URLSearchParams({id: changedValues.join(',')})});
|
||||
}
|
||||
issueSidebarReloadConfirmDraftComment();
|
||||
}
|
||||
|
||||
async doUpdate() {
|
||||
const changedValues = this.collectCheckedValues();
|
||||
if (this.initialValues.join(',') === changedValues.join(',')) return;
|
||||
this.updateUiList(changedValues);
|
||||
if (this.updateUrl) await this.updateToBackend(changedValues);
|
||||
this.initialValues = changedValues;
|
||||
}
|
||||
|
||||
async onChange() {
|
||||
if (this.selectionMode === 'single') {
|
||||
await this.doUpdate();
|
||||
fomanticQuery(this.elDropdown).dropdown('hide');
|
||||
}
|
||||
}
|
||||
|
||||
async onItemClick(e) {
|
||||
const elItem = (e.target as HTMLElement).closest('.item');
|
||||
if (!elItem) return;
|
||||
e.preventDefault();
|
||||
if (elItem.hasAttribute('data-can-change') && elItem.getAttribute('data-can-change') !== 'true') return;
|
||||
|
||||
if (elItem.matches('.clear-selection')) {
|
||||
queryElems(elDropdown, '.menu > .item', (el) => el.classList.remove('checked'));
|
||||
elComboValue.value = '';
|
||||
queryElems(this.elDropdown, '.menu > .item', (el) => el.classList.remove('checked'));
|
||||
this.elComboValue.value = '';
|
||||
this.onChange();
|
||||
return;
|
||||
}
|
||||
|
||||
const scope = elItem.getAttribute('data-scope');
|
||||
if (scope) {
|
||||
// scoped items could only be checked one at a time
|
||||
const elSelected = elDropdown.querySelector<HTMLElement>(`.menu > .item.checked[data-scope="${CSS.escape(scope)}"]`);
|
||||
const elSelected = this.elDropdown.querySelector<HTMLElement>(`.menu > .item.checked[data-scope="${CSS.escape(scope)}"]`);
|
||||
if (elSelected === elItem) {
|
||||
elItem.classList.toggle('checked');
|
||||
} else {
|
||||
queryElems(elDropdown, `.menu > .item[data-scope="${CSS.escape(scope)}"]`, (el) => el.classList.remove('checked'));
|
||||
queryElems(this.elDropdown, `.menu > .item[data-scope="${CSS.escape(scope)}"]`, (el) => el.classList.remove('checked'));
|
||||
elItem.classList.toggle('checked', true);
|
||||
}
|
||||
} else {
|
||||
elItem.classList.toggle('checked');
|
||||
}
|
||||
elComboValue.value = collectCheckedValues(elDropdown).join(',');
|
||||
});
|
||||
|
||||
const updateToBackend = async (changedValues) => {
|
||||
let changed = false;
|
||||
for (const value of initialValues) {
|
||||
if (!changedValues.includes(value)) {
|
||||
await POST(updateUrl, {data: new URLSearchParams({action: 'detach', id: value})});
|
||||
changed = true;
|
||||
if (this.selectionMode === 'multiple') {
|
||||
elItem.classList.toggle('checked');
|
||||
} else {
|
||||
queryElems(this.elDropdown, `.menu > .item.checked`, (el) => el.classList.remove('checked'));
|
||||
elItem.classList.toggle('checked', true);
|
||||
}
|
||||
}
|
||||
for (const value of changedValues) {
|
||||
if (!initialValues.includes(value)) {
|
||||
await POST(updateUrl, {data: new URLSearchParams({action: 'attach', id: value})});
|
||||
changed = true;
|
||||
this.elComboValue.value = this.collectCheckedValues().join(',');
|
||||
this.onChange();
|
||||
}
|
||||
|
||||
async onHide() {
|
||||
if (this.selectionMode === 'multiple') this.doUpdate();
|
||||
}
|
||||
|
||||
init() {
|
||||
// init the checked items from initial value
|
||||
if (this.elComboValue.value && this.elComboValue.value !== '0' && !queryElems(this.elDropdown, `.menu > .item.checked`).length) {
|
||||
const values = this.elComboValue.value.split(',');
|
||||
for (const value of values) {
|
||||
const elItem = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
|
||||
elItem?.classList.add('checked');
|
||||
}
|
||||
this.updateUiList(values);
|
||||
}
|
||||
if (changed) issueSidebarReloadConfirmDraftComment();
|
||||
};
|
||||
this.initialValues = this.collectCheckedValues();
|
||||
|
||||
const syncUiList = (changedValues) => {
|
||||
const elEmptyTip = elList.querySelector('.item.empty-list');
|
||||
queryElemChildren(elList, '.item:not(.empty-list)', (el) => el.remove());
|
||||
for (const value of changedValues) {
|
||||
const el = elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
|
||||
const listItem = el.cloneNode(true) as HTMLElement;
|
||||
queryElems(listItem, '.item-check-mark, .item-secondary-info', (el) => el.remove());
|
||||
elList.append(listItem);
|
||||
}
|
||||
const hasItems = Boolean(elList.querySelector('.item:not(.empty-list)'));
|
||||
toggleElem(elEmptyTip, !hasItems);
|
||||
};
|
||||
this.elDropdown.addEventListener('click', (e) => this.onItemClick(e));
|
||||
|
||||
fomanticQuery(elDropdown).dropdown('setting', {
|
||||
action: 'nothing', // do not hide the menu if user presses Enter
|
||||
fullTextSearch: 'exact',
|
||||
async onHide() {
|
||||
// TODO: support "Esc" to cancel the selection. Use partial page loading to avoid losing inputs.
|
||||
const changedValues = collectCheckedValues(elDropdown);
|
||||
syncUiList(changedValues);
|
||||
if (updateUrl) await updateToBackend(changedValues);
|
||||
initialValues = changedValues;
|
||||
},
|
||||
});
|
||||
fomanticQuery(this.elDropdown).dropdown('setting', {
|
||||
action: 'nothing', // do not hide the menu if user presses Enter
|
||||
fullTextSearch: 'exact',
|
||||
onHide: () => this.onHide(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function initIssueSidebarComboList(container: HTMLElement) {
|
||||
new IssueSidebarComboList(container).init();
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
A sidebar combo (dropdown+list) is like this:
|
||||
|
||||
```html
|
||||
<div class="issue-sidebar-combo" data-update-url="...">
|
||||
<div class="issue-sidebar-combo" data-selection-mode="..." data-update-url="...">
|
||||
<input class="combo-value" name="..." type="hidden" value="...">
|
||||
<div class="ui dropdown">
|
||||
<div class="menu">
|
||||
|
@ -25,3 +25,7 @@ If there is `data-update-url`, it also calls backend to attach/detach the change
|
|||
Also, the changed items will be syncronized to the `ui list` items.
|
||||
|
||||
The items with the same data-scope only allow one selected at a time.
|
||||
|
||||
The dropdown selection could work in 2 modes:
|
||||
* single: only one item could be selected, it updates immediately when the item is selected.
|
||||
* multiple: multiple items could be selected, it defers the update until the dropdown is hidden.
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
import $ from 'jquery';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {updateIssuesMeta} from './repo-common.ts';
|
||||
import {svg} from '../svg.ts';
|
||||
import {htmlEscape} from 'escape-goat';
|
||||
import {queryElems, toggleElem} from '../utils/dom.ts';
|
||||
import {initIssueSidebarComboList, issueSidebarReloadConfirmDraftComment} from './repo-issue-sidebar-combolist.ts';
|
||||
import {initIssueSidebarComboList} from './repo-issue-sidebar-combolist.ts';
|
||||
|
||||
function initBranchSelector() {
|
||||
const elSelectBranch = document.querySelector('.ui.dropdown.select-branch');
|
||||
|
@ -34,212 +31,6 @@ function initBranchSelector() {
|
|||
});
|
||||
}
|
||||
|
||||
// List submits
|
||||
function initListSubmits(selector, outerSelector) {
|
||||
const $list = $(`.ui.${outerSelector}.list`);
|
||||
const $noSelect = $list.find('.no-select');
|
||||
const $listMenu = $(`.${selector} .menu`);
|
||||
let hasUpdateAction = $listMenu.data('action') === 'update';
|
||||
const items = {};
|
||||
|
||||
$(`.${selector}`).dropdown({
|
||||
'action': 'nothing', // do not hide the menu if user presses Enter
|
||||
fullTextSearch: 'exact',
|
||||
async onHide() {
|
||||
hasUpdateAction = $listMenu.data('action') === 'update'; // Update the var
|
||||
if (hasUpdateAction) {
|
||||
// TODO: Add batch functionality and make this 1 network request.
|
||||
const itemEntries = Object.entries(items);
|
||||
for (const [elementId, item] of itemEntries) {
|
||||
await updateIssuesMeta(
|
||||
item['update-url'],
|
||||
item['action'],
|
||||
item['issue-id'],
|
||||
elementId,
|
||||
);
|
||||
}
|
||||
if (itemEntries.length) {
|
||||
issueSidebarReloadConfirmDraftComment();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
$listMenu.find('.item:not(.no-select)').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
if (this.classList.contains('ban-change')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
hasUpdateAction = $listMenu.data('action') === 'update'; // Update the var
|
||||
|
||||
const clickedItem = this; // eslint-disable-line unicorn/no-this-assignment
|
||||
const scope = this.getAttribute('data-scope');
|
||||
|
||||
$(this).parent().find('.item').each(function () {
|
||||
if (scope) {
|
||||
// Enable only clicked item for scoped labels
|
||||
if (this.getAttribute('data-scope') !== scope) {
|
||||
return;
|
||||
}
|
||||
if (this !== clickedItem && !this.classList.contains('checked')) {
|
||||
return;
|
||||
}
|
||||
} else if (this !== clickedItem) {
|
||||
// Toggle for other labels
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.classList.contains('checked')) {
|
||||
$(this).removeClass('checked');
|
||||
$(this).find('.octicon-check').addClass('tw-invisible');
|
||||
if (hasUpdateAction) {
|
||||
if (!($(this).data('id') in items)) {
|
||||
items[$(this).data('id')] = {
|
||||
'update-url': $listMenu.data('update-url'),
|
||||
action: 'detach',
|
||||
'issue-id': $listMenu.data('issue-id'),
|
||||
};
|
||||
} else {
|
||||
delete items[$(this).data('id')];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$(this).addClass('checked');
|
||||
$(this).find('.octicon-check').removeClass('tw-invisible');
|
||||
if (hasUpdateAction) {
|
||||
if (!($(this).data('id') in items)) {
|
||||
items[$(this).data('id')] = {
|
||||
'update-url': $listMenu.data('update-url'),
|
||||
action: 'attach',
|
||||
'issue-id': $listMenu.data('issue-id'),
|
||||
};
|
||||
} else {
|
||||
delete items[$(this).data('id')];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: Which thing should be done for choosing review requests
|
||||
// to make chosen items be shown on time here?
|
||||
if (selector === 'select-assignees-modify') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const listIds = [];
|
||||
$(this).parent().find('.item').each(function () {
|
||||
if (this.classList.contains('checked')) {
|
||||
listIds.push($(this).data('id'));
|
||||
$($(this).data('id-selector')).removeClass('tw-hidden');
|
||||
} else {
|
||||
$($(this).data('id-selector')).addClass('tw-hidden');
|
||||
}
|
||||
});
|
||||
if (!listIds.length) {
|
||||
$noSelect.removeClass('tw-hidden');
|
||||
} else {
|
||||
$noSelect.addClass('tw-hidden');
|
||||
}
|
||||
$($(this).parent().data('id')).val(listIds.join(','));
|
||||
return false;
|
||||
});
|
||||
$listMenu.find('.no-select.item').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
if (hasUpdateAction) {
|
||||
(async () => {
|
||||
await updateIssuesMeta(
|
||||
$listMenu.data('update-url'),
|
||||
'clear',
|
||||
$listMenu.data('issue-id'),
|
||||
'',
|
||||
);
|
||||
issueSidebarReloadConfirmDraftComment();
|
||||
})();
|
||||
}
|
||||
|
||||
$(this).parent().find('.item').each(function () {
|
||||
$(this).removeClass('checked');
|
||||
$(this).find('.octicon-check').addClass('tw-invisible');
|
||||
});
|
||||
|
||||
if (selector === 'select-assignees-modify') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$list.find('.item').each(function () {
|
||||
$(this).addClass('tw-hidden');
|
||||
});
|
||||
$noSelect.removeClass('tw-hidden');
|
||||
$($(this).parent().data('id')).val('');
|
||||
});
|
||||
}
|
||||
|
||||
function selectItem(select_id, input_id) {
|
||||
const $menu = $(`${select_id} .menu`);
|
||||
const $list = $(`.ui${select_id}.list`);
|
||||
const hasUpdateAction = $menu.data('action') === 'update';
|
||||
|
||||
$menu.find('.item:not(.no-select)').on('click', function () {
|
||||
$(this).parent().find('.item').each(function () {
|
||||
$(this).removeClass('selected active');
|
||||
});
|
||||
|
||||
$(this).addClass('selected active');
|
||||
if (hasUpdateAction) {
|
||||
(async () => {
|
||||
await updateIssuesMeta(
|
||||
$menu.data('update-url'),
|
||||
'',
|
||||
$menu.data('issue-id'),
|
||||
$(this).data('id'),
|
||||
);
|
||||
issueSidebarReloadConfirmDraftComment();
|
||||
})();
|
||||
}
|
||||
|
||||
let icon = '';
|
||||
if (input_id === '#milestone_id') {
|
||||
icon = svg('octicon-milestone', 18, 'tw-mr-2');
|
||||
} else if (input_id === '#project_id') {
|
||||
icon = svg('octicon-project', 18, 'tw-mr-2');
|
||||
} else if (input_id === '#assignee_id') {
|
||||
icon = `<img class="ui avatar image tw-mr-2" alt="avatar" src=${$(this).data('avatar')}>`;
|
||||
}
|
||||
|
||||
$list.find('.selected').html(`
|
||||
<a class="item muted sidebar-item-link" href="${htmlEscape(this.getAttribute('data-href'))}">
|
||||
${icon}
|
||||
${htmlEscape(this.textContent)}
|
||||
</a>
|
||||
`);
|
||||
|
||||
$(`.ui${select_id}.list .no-select`).addClass('tw-hidden');
|
||||
$(input_id).val($(this).data('id'));
|
||||
});
|
||||
$menu.find('.no-select.item').on('click', function () {
|
||||
$(this).parent().find('.item:not(.no-select)').each(function () {
|
||||
$(this).removeClass('selected active');
|
||||
});
|
||||
|
||||
if (hasUpdateAction) {
|
||||
(async () => {
|
||||
await updateIssuesMeta(
|
||||
$menu.data('update-url'),
|
||||
'',
|
||||
$menu.data('issue-id'),
|
||||
$(this).data('id'),
|
||||
);
|
||||
issueSidebarReloadConfirmDraftComment();
|
||||
})();
|
||||
}
|
||||
|
||||
$list.find('.selected').html('');
|
||||
$list.find('.no-select').removeClass('tw-hidden');
|
||||
$(input_id).val('');
|
||||
});
|
||||
}
|
||||
|
||||
function initRepoIssueDue() {
|
||||
const form = document.querySelector<HTMLFormElement>('.issue-due-form');
|
||||
if (!form) return;
|
||||
|
@ -257,14 +48,6 @@ export function initRepoIssueSidebar() {
|
|||
initBranchSelector();
|
||||
initRepoIssueDue();
|
||||
|
||||
// TODO: refactor the legacy initListSubmits&selectItem to initIssueSidebarComboList
|
||||
initListSubmits('select-assignees', 'assignees');
|
||||
initListSubmits('select-assignees-modify', 'assignees');
|
||||
selectItem('.select-assignee', '#assignee_id');
|
||||
|
||||
selectItem('.select-project', '#project_id');
|
||||
selectItem('.select-milestone', '#milestone_id');
|
||||
|
||||
// init the combo list: a dropdown for selecting items, and a list for showing selected items and related actions
|
||||
queryElems<HTMLElement>(document, '.issue-sidebar-combo', (el) => initIssueSidebarComboList(el));
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue