impress/app/assets/javascripts/closet_hangers/index.js
Emi Matchu 1a1615e0ad Oops, fix regression of editor features on item lists page
I changed the type of this tag without realizing the JS references it
by both class and `div`!

I think at the time this was a perf suggestion for jQuery, because the
best way to query by class name was to query by tag first then filter?
It's possible our jQuery still does this, but I don't imagine it's very
relevant today, so I'll just remove that for better guarding against
similar bugs in the future instead.
2024-02-22 15:52:40 -08:00

852 lines
23 KiB
JavaScript

(function () {
var hangersInitCallbacks = [];
function onHangersInit(callback) {
hangersInitCallbacks[hangersInitCallbacks.length] = callback;
}
function hangersInit() {
for (var i = 0; i < hangersInitCallbacks.length; i++) {
try {
hangersInitCallbacks[i]();
} catch (error) {
console.error(error);
}
}
}
/*
Hanger groups
*/
var hangerGroups = [];
$(".closet-hangers-group").each(function () {
var el = $(this);
var lists = [];
el.find("div.closet-list").each(function () {
var el = $(this);
var id = el.attr("data-id");
if (id) {
lists[lists.length] = {
id: parseInt(id, 10),
label: el.find("h4").text(),
};
}
});
hangerGroups[hangerGroups.length] = {
label: el.find("h3").text(),
lists: lists,
owned: el.attr("data-owned") == "true",
};
});
$(".closet-hangers-group span.toggle").live("click", function () {
$(this).closest(".closet-hangers-group").toggleClass("hidden");
});
var hangersElQuery = "#closet-hangers";
var hangersEl = $(hangersElQuery);
/*
Compare with Your Items
*/
$("#toggle-compare").click(function () {
hangersEl.toggleClass("comparing");
});
// Read the item IDs of trade matches from the meta tags.
const ownedIds =
document
.querySelector("meta[name=trade-matches-owns]")
?.getAttribute("value")
?.split(",") ?? [];
const wantedIds =
document
.querySelector("meta[name=trade-matches-wants]")
?.getAttribute("value")
?.split(",") ?? [];
// Apply the `user-owns` and `user-wants` classes to the relevant entries.
// This both provides immediate visual feedback, and sets up "Compare with
// Your Items" to toggle to just them!
//
// NOTE: The motivation here is caching: this allows us to share a cache of
// the closet list contents across all users, without `user-owns` or
// `user-wants` classes for one specific user getting cached and reused.
const hangerEls = document.querySelectorAll("#closet-hangers .object");
for (const hangerEl of hangerEls) {
const itemId = hangerEl.getAttribute("data-item-id");
if (ownedIds.includes(itemId)) {
hangerEl.classList.add("user-owns");
}
if (wantedIds.includes(itemId)) {
hangerEl.classList.add("user-wants");
}
}
/*
Hanger forms
*/
var body = $(document.body).addClass("js");
if (!body.hasClass("current-user")) return false;
// When we get hangers HTML, add the controls. We do this in JS rather than
// in the HTML for caching, since otherwise the requests can take forever.
// If there were another way to add hangers, then we'd have to worry about
// that, but, right now, the only way to create a new hanger from this page
// is through the autocompleter, which reinitializes anyway. Geez, this thing
// is begging for a rewrite, but today we're here for performance.
$("#closet-hanger-update-tmpl").template("updateFormTmpl");
$("#closet-hanger-destroy-tmpl").template("destroyFormTmpl");
onHangersInit(function () {
// Super-lame hack to get the user ID from where it already is :/
var currentUserId = itemsSearchForm.data("current-user-id");
$("#closet-hangers .closet-hangers-group").each(function () {
var groupEl = $(this);
var owned = groupEl.data("owned");
groupEl.find("div.closet-list").each(function () {
var listEl = $(this);
var listId = listEl.data("id");
listEl.find("div.object").each(function () {
var hangerEl = $(this);
var hangerId = hangerEl.data("id");
var quantityEl = hangerEl.find("div.quantity");
var quantity = hangerEl.data("quantity");
// Ooh, this part is weird. We only want the name to be linked, so
// lift everything else out.
var checkboxId = "hanger-selected-" + hangerId;
var label = $("<label />", { for: checkboxId });
var link = hangerEl.children("a");
link.children(":not(.name)").detach().appendTo(label);
link.detach().appendTo(label);
var checkbox = $("<input />", {
type: "checkbox",
id: checkboxId,
}).appendTo(hangerEl);
label.appendTo(hangerEl);
// I don't usually like to _blank things, but it's too easy to click
// the text when you didn't mean to and lose your selection work.
link.attr("target", "_blank");
$.tmpl("updateFormTmpl", {
user_id: currentUserId,
closet_hanger_id: hangerId,
quantity: quantity,
list_id: listId,
owned: owned,
}).appendTo(quantityEl);
$.tmpl("destroyFormTmpl", {
user_id: currentUserId,
closet_hanger_id: hangerId,
}).appendTo(hangerEl);
});
});
});
});
$.fn.liveDraggable = function (opts) {
this.live("mouseover", function () {
if (!$(this).data("init")) {
$(this).data("init", true).draggable(opts);
}
});
};
$.fn.disableForms = function () {
return this.data("formsDisabled", true)
.find("input")
.attr("disabled", "disabled")
.end();
};
$.fn.enableForms = function () {
return this.data("formsDisabled", false)
.find("input")
.removeAttr("disabled")
.end();
};
$.fn.hasChanged = function () {
return this.attr("data-previous-value") != this.val();
};
$.fn.revertValue = function () {
return this.each(function () {
var el = $(this);
el.val(el.attr("data-previous-value"));
});
};
$.fn.storeValue = function () {
return this.each(function () {
var el = $(this);
el.attr("data-previous-value", el.val());
});
};
$.fn.insertIntoSortedList = function (list, compare) {
var newChild = this,
inserted = false;
list.children().each(function () {
if (compare(newChild, $(this)) < 1) {
newChild.insertBefore(this);
inserted = true;
return false;
}
});
if (!inserted) newChild.appendTo(list);
return this;
};
function handleSaveError(xhr, action) {
try {
var data = $.parseJSON(xhr.responseText);
} catch (e) {
var data = {};
}
if (typeof data.errors != "undefined") {
$.jGrowl("Error " + action + ": " + data.errors.join(", "));
} else {
$.jGrowl("We had trouble " + action + " just now. Try again?");
}
}
function objectRemoved(objectWrapper) {
objectWrapper.hide(250, function () {
objectWrapper.remove();
updateBulkActions();
});
}
function compareItemsByName(a, b) {
return a.find("span.name").text().localeCompare(b.find("span.name").text());
}
function findList(owned, id, item) {
if (id) {
return $("#closet-list-" + id);
} else {
return $(
".closet-hangers-group[data-owned=" +
owned +
"] div.closet-list.unlisted",
);
}
}
function updateListHangersCount(el) {
el.attr("data-hangers-count", el.find("div.object").length);
}
function moveItemToList(item, owned, listId) {
var newList = findList(owned, listId, item);
var oldList = item.closest("div.closet-list");
var hangersWrapper = newList.find("div.closet-list-hangers");
item.insertIntoSortedList(hangersWrapper, compareItemsByName);
updateListHangersCount(oldList);
updateListHangersCount(newList);
}
function submitUpdateForm(form) {
if (form.data("loading")) return false;
var quantityEl = form.children("input[name=closet_hanger[quantity]]");
var ownedEl = form.children("input[name=closet_hanger[owned]]");
var listEl = form.children("input[name=closet_hanger[list_id]]");
var listChanged = ownedEl.hasChanged() || listEl.hasChanged();
if (listChanged || quantityEl.hasChanged()) {
var objectWrapper = form.closest(".object").addClass("loading");
var newQuantity = quantityEl.val();
var quantitySpan = objectWrapper.find(".quantity span").text(newQuantity);
objectWrapper.attr("data-quantity", newQuantity);
var data = form.serialize(); // get data before disabling inputs
objectWrapper.disableForms();
form.data("loading", true);
if (listChanged)
moveItemToList(objectWrapper, ownedEl.val(), listEl.val());
$.ajax({
url: form.attr("action") + ".json",
type: "post",
data: data,
dataType: "json",
complete: function (data) {
if (quantityEl.val() == 0) {
objectRemoved(objectWrapper);
} else {
objectWrapper.removeClass("loading").enableForms();
}
form.data("loading", false);
},
success: function () {
// Now that the move was successful, let's merge it with any
// conflicting hangers
var id = objectWrapper.attr("data-item-id");
var conflictingHanger = findList(
ownedEl.val(),
listEl.val(),
objectWrapper,
)
.find("div[data-item-id=" + id + "]")
.not(objectWrapper);
if (conflictingHanger.length) {
var conflictingQuantity = parseInt(
conflictingHanger.attr("data-quantity"),
10,
);
var currentQuantity = parseInt(newQuantity, 10);
var mergedQuantity = conflictingQuantity + currentQuantity;
quantitySpan.text(mergedQuantity);
quantityEl.val(mergedQuantity);
objectWrapper.attr("data-quantity", mergedQuantity);
conflictingHanger.remove();
}
quantityEl.storeValue();
ownedEl.storeValue();
listEl.storeValue();
updateBulkActions();
},
error: function (xhr) {
quantityEl.revertValue();
ownedEl.revertValue();
listEl.revertValue();
if (listChanged)
moveItemToList(objectWrapper, ownedEl.val(), listEl.val());
quantitySpan.text(quantityEl.val());
handleSaveError(xhr, "updating the quantity");
},
});
}
}
$(hangersElQuery + " form.closet-hanger-update").live("submit", function (e) {
e.preventDefault();
submitUpdateForm($(this));
});
function editableInputs() {
return $(hangersElQuery).find(
"input[name=closet_hanger[quantity]], " +
"input[name=closet_hanger[owned]], " +
"input[name=closet_hanger[list_id]]",
);
}
$(hangersElQuery + "input[name=closet_hanger[quantity]]")
.live("change", function () {
submitUpdateForm($(this).parent());
})
.storeValue();
onHangersInit(function () {
editableInputs().storeValue();
});
$(hangersElQuery + " div.object")
.live("mouseleave", function () {
submitUpdateForm($(this).find("form.closet-hanger-update"));
})
.liveDraggable({
appendTo: "#closet-hangers",
distance: 20,
helper: "clone",
revert: "invalid",
});
$(hangersElQuery + " form.closet-hanger-destroy").live(
"submit",
function (e) {
e.preventDefault();
var form = $(this);
var button = form.children("input[type=submit]").val("Removing…");
var objectWrapper = form.closest(".object").addClass("loading");
var data = form.serialize(); // get data before disabling inputs
objectWrapper.addClass("loading").disableForms();
$.ajax({
url: form.attr("action") + ".json",
type: "post",
data: data,
dataType: "json",
complete: function () {
button.val("Remove");
},
success: function () {
objectRemoved(objectWrapper);
},
error: function () {
objectWrapper.removeClass("loading").enableForms();
$.jGrowl("Error removing item. Try again?");
},
});
},
);
$(hangersElQuery + " .select-all").live("click", function (e) {
var checkboxes = $(this)
.closest(".closet-list")
.find(".object input[type=checkbox]");
var allChecked = true;
checkboxes.each(function () {
if (!this.checked) {
allChecked = false;
return false;
}
});
checkboxes.attr("checked", !allChecked);
updateBulkActions(); // setting the checked prop doesn't fire change events
});
function getCheckboxes() {
return $(hangersElQuery + " input[type=checkbox]");
}
function getCheckedIds() {
var checkedIds = [];
getCheckboxes()
.filter(":checked")
.each(function () {
if (this.checked) checkedIds.push(this.id);
});
return checkedIds;
}
getCheckboxes().live("change", updateBulkActions);
function updateBulkActions() {
var checkedCount = getCheckboxes().filter(":checked").length;
$(".bulk-actions").attr("data-target-count", checkedCount);
$(".bulk-actions-target-count").text(checkedCount);
}
$(".bulk-actions-move-all").bind("submit", function (e) {
// TODO: DRY
e.preventDefault();
var form = $(this);
var data = form.serializeArray();
data.push({
name: "return_to",
value: window.location.pathname + window.location.search,
});
var checkedBoxes = getCheckboxes().filter(":checked");
checkedBoxes.each(function () {
data.push({
name: "ids[]",
value: $(this).closest(".object").attr("data-id"),
});
});
$.ajax({
url: form.attr("action"),
type: form.attr("method"),
data: data,
success: function (html) {
var doc = $(html);
hangersEl.html(doc.find("#closet-hangers").html());
hangersInit();
updateBulkActions(); // don't want to maintain checked; deselect em all
doc
.find(".flash")
.hide()
.insertBefore(hangersEl)
.show(500)
.delay(5000)
.hide(250);
itemsSearchField.val("");
},
error: function (xhr) {
handleSaveError(xhr, "moving these items");
},
});
});
$(".bulk-actions-remove-all").bind("submit", function (e) {
e.preventDefault();
var form = $(this);
var hangerIds = [];
var checkedBoxes = getCheckboxes().filter(":checked");
var hangerEls = $();
checkedBoxes.each(function () {
hangerEls = hangerEls.add($(this).closest(".object"));
});
hangerEls.each(function () {
hangerIds.push($(this).attr("data-id"));
});
$.ajax({
url: form.attr("action") + ".json?" + $.param({ ids: hangerIds }),
type: "delete",
dataType: "json",
success: function () {
objectRemoved(hangerEls);
},
error: function () {
$.jGrowl("Error removing items. Try again?");
},
});
});
$(".bulk-actions-deselect-all").bind("click", function (e) {
getCheckboxes().filter(":checked").attr("checked", false);
updateBulkActions();
});
function maintainCheckboxes(fn) {
var checkedIds = getCheckedIds();
fn();
checkedIds.forEach(function (id) {
document.getElementById(id).checked = true;
});
updateBulkActions();
}
/*
Search, autocomplete
*/
var itemsSearchForm = $("#closet-hangers-items-search[data-current-user-id]");
var itemsSearchField = itemsSearchForm.children("input[name=q]");
itemsSearchField.autocomplete({
select: function (e, ui) {
if (ui.item.is_item) {
// Let the autocompleter finish up this search before starting a new one
setTimeout(function () {
itemsSearchField.autocomplete("search", ui.item);
}, 0);
} else {
var item = ui.item.item;
var group = ui.item.group;
itemsSearchField.addClass("loading");
var closetHanger = {
owned: group.owned,
list_id: ui.item.list ? ui.item.list.id : "",
};
if (!item.hasHanger) closetHanger.quantity = 1;
$.ajax({
url:
"/user/" +
itemsSearchForm.data("current-user-id") +
"/items/" +
item.id +
"/closet_hangers",
type: "post",
data: {
closet_hanger: closetHanger,
return_to: window.location.pathname + window.location.search,
},
complete: function () {
itemsSearchField.removeClass("loading");
},
success: function (html) {
var doc = $(html);
maintainCheckboxes(function () {
hangersEl.html(doc.find("#closet-hangers").html());
hangersInit();
});
doc
.find(".flash")
.hide()
.insertBefore(hangersEl)
.show(500)
.delay(5000)
.hide(250);
itemsSearchField.val("");
},
error: function (xhr) {
handleSaveError(xhr, "adding the item");
},
});
}
},
source: function (input, callback) {
if (typeof input.term == "string") {
// user-typed query
$.getJSON("/items.json?q=" + input.term, function (data) {
var output = [];
var items = data.items;
for (var i in items) {
items[i].label = items[i].name;
items[i].is_item = true;
output[output.length] = items[i];
}
callback(output);
});
} else {
// item was chosen, now choose a group to insert
var groupInserts = [],
group;
var item = input.term,
itemEl,
occupiedGroups,
hasHanger;
for (var i in hangerGroups) {
group = hangerGroups[i];
itemEl = $(
".closet-hangers-group[data-owned=" +
group.owned +
"] div.object[data-item-id=" +
item.id +
"]",
);
occupiedGroups = itemEl.closest(".closet-list");
hasHanger = occupiedGroups.filter(".unlisted").length > 0;
groupInserts[groupInserts.length] = {
group: group,
item: item,
label: item.label,
hasHanger: hasHanger,
};
for (var i = 0; i < group.lists.length; i++) {
hasHanger =
occupiedGroups.filter("[data-id=" + group.lists[i].id + "]")
.length > 0;
groupInserts[groupInserts.length] = {
group: group,
item: item,
label: item.label,
list: group.lists[i],
hasHanger: hasHanger,
};
}
}
callback(groupInserts);
}
},
});
var autocompleter = itemsSearchField.data("autocomplete");
autocompleter._renderItem = function (ul, item) {
var li = $("<li></li>").data("item.autocomplete", item);
if (item.is_item) {
// these are items from the server
$("#autocomplete-item-tmpl").tmpl({ item_name: item.label }).appendTo(li);
} else if (item.list) {
// these are list inserts
var listName = item.list.label;
if (item.hasHanger) {
$("#autocomplete-already-in-collection-tmpl")
.tmpl({ collection_name: listName })
.appendTo(li);
} else {
$("#autocomplete-add-to-list-tmpl")
.tmpl({ list_name: listName })
.appendTo(li);
}
li.addClass("closet-list-autocomplete-item");
} else {
// these are group inserts
var groupName = item.group.label;
if (!item.hasHanger) {
$("#autocomplete-add-to-group-tmpl")
.tmpl({ group_name: groupName.replace(/\s+$/, "") })
.appendTo(li);
} else {
$("#autocomplete-already-in-collection-tmpl")
.tmpl({ collection_name: groupName })
.appendTo(li);
}
li.addClass("closet-hangers-group-autocomplete-item");
}
return li.appendTo(ul);
};
/*
Contact Neopets username form
*/
var contactEl = $("#closet-hangers-contact");
var contactForm = contactEl.children("form");
var contactField = contactForm.children("select");
var contactAddOption = $("<option/>", {
text: contactField.attr("data-new-text"),
value: -1,
});
contactAddOption.appendTo(contactField);
var currentUserId = $("meta[name=current-user-id]").attr("content");
function submitContactForm() {
var data = contactForm.serialize();
contactForm.disableForms();
$.ajax({
url: contactForm.attr("action") + ".json",
type: "post",
data: data,
dataType: "json",
complete: function () {
contactForm.enableForms();
},
error: function (xhr) {
handleSaveError(xhr, "saving Neopets username");
},
});
}
contactField.change(function (e) {
if (contactField.val() < 0) {
var newUsername = $.trim(
prompt(contactField.attr("data-new-prompt"), ""),
);
if (newUsername) {
$.ajax({
url: "/user/" + currentUserId + "/neopets-connections",
type: "POST",
data: { neopets_connection: { neopets_username: newUsername } },
dataType: "json",
success: function (connection) {
var newOption = $("<option/>", {
text: newUsername,
value: connection.id,
});
newOption.insertBefore(contactAddOption);
contactField.val(connection.id);
submitContactForm();
},
});
}
} else {
submitContactForm();
}
});
/*
Hanger list controls
*/
$("input[type=submit][data-confirm]").live("click", function (e) {
if (!confirm(this.getAttribute("data-confirm"))) e.preventDefault();
});
/*
Closet list droppable
*/
onHangersInit(function () {
$("div.closet-list").droppable({
accept: "div.object",
activate: function () {
$(this)
.find(".closet-list-content")
.animate({ opacity: 0, height: 100 }, 250);
},
activeClass: "droppable-active",
deactivate: function () {
$(this)
.find(".closet-list-content")
.css("height", "auto")
.animate({ opacity: 1 }, 250);
},
drop: function (e, ui) {
var form = ui.draggable.find("form.closet-hanger-update");
form
.find("input[name=closet_hanger[list_id]]")
.val(this.getAttribute("data-id"));
form
.find("input[name=closet_hanger[owned]]")
.val($(this).closest(".closet-hangers-group").attr("data-owned"));
submitUpdateForm(form);
},
});
});
/*
Visibility Descriptions
*/
function updateVisibilityDescription() {
var descriptions = $(this)
.closest(".visibility-form")
.find("ul.visibility-descriptions");
descriptions.children("li.current").removeClass("current");
descriptions
.children("li[data-id=" + $(this).val() + "]")
.addClass("current");
}
function visibilitySelects() {
return $("form.visibility-form select");
}
visibilitySelects().live("change", updateVisibilityDescription);
onHangersInit(function () {
visibilitySelects().each(updateVisibilityDescription);
});
/*
Help
*/
$("#toggle-help").click(function () {
$("#closet-hangers-help").toggleClass("hidden");
});
/*
Share URL
*/
$("#closet-hangers-share-box")
.mouseover(function () {
$(this).focus();
})
.mouseout(function () {
$(this).blur();
});
/*
Initialize
*/
hangersInit();
})();