Refactor date filtering and use native date picker

This commit is contained in:
Alex Ling 2021-11-20 08:10:51 +00:00
parent 87e54aa89c
commit e44213960f
2 changed files with 241 additions and 189 deletions

View File

@ -6,8 +6,8 @@ const component = () => {
chapters: undefined, // undefined: not searched yet, []: empty chapters: undefined, // undefined: not searched yet, []: empty
manga: undefined, // undefined: not searched yet, []: empty manga: undefined, // undefined: not searched yet, []: empty
allChapters: [], allChapters: [],
query: '', query: "",
mangaTitle: '', mangaTitle: "",
searching: false, searching: false,
adding: false, adding: false,
sortOptions: [], sortOptions: [],
@ -16,73 +16,82 @@ const component = () => {
chaptersLimit: 500, chaptersLimit: 500,
listManga: false, listManga: false,
subscribing: false, subscribing: false,
subscriptionName: '', subscriptionName: "",
init() { init() {
const tableObserver = new MutationObserver(() => { const tableObserver = new MutationObserver(() => {
console.log('table mutated'); console.log("table mutated");
$('#selectable').selectable({ $("#selectable").selectable({
filter: 'tr' filter: "tr",
}); });
}); });
tableObserver.observe($('table').get(0), { tableObserver.observe($("table").get(0), {
childList: true, childList: true,
subtree: true subtree: true,
}); });
fetch(`${base_url}api/admin/plugin`) fetch(`${base_url}api/admin/plugin`)
.then(res => res.json()) .then((res) => res.json())
.then(data => { .then((data) => {
if (!data.success) if (!data.success) throw new Error(data.error);
throw new Error(data.error);
this.plugins = data.plugins; this.plugins = data.plugins;
const pid = localStorage.getItem('plugin'); const pid = localStorage.getItem("plugin");
if (pid && this.plugins.map(p => p.id).includes(pid)) if (pid && this.plugins.map((p) => p.id).includes(pid))
return this.loadPlugin(pid); return this.loadPlugin(pid);
if (this.plugins.length > 0) if (this.plugins.length > 0)
this.loadPlugin(this.plugins[0].id); this.loadPlugin(this.plugins[0].id);
}) })
.catch(e => { .catch((e) => {
alert('danger', `Failed to list the available plugins. Error: ${e}`); alert(
"danger",
`Failed to list the available plugins. Error: ${e}`
);
}); });
}, },
loadPlugin(pid) { loadPlugin(pid) {
fetch(`${base_url}api/admin/plugin/info?${new URLSearchParams({ fetch(
plugin: pid `${base_url}api/admin/plugin/info?${new URLSearchParams({
})}`) plugin: pid,
.then(res => res.json()) })}`
.then(data => { )
if (!data.success) .then((res) => res.json())
throw new Error(data.error); .then((data) => {
if (!data.success) throw new Error(data.error);
this.info = data.info; this.info = data.info;
this.pid = pid; this.pid = pid;
}) })
.catch(e => { .catch((e) => {
alert('danger', `Failed to get plugin metadata. Error: ${e}`); alert(
"danger",
`Failed to get plugin metadata. Error: ${e}`
);
}); });
}, },
pluginChanged() { pluginChanged() {
this.loadPlugin(this.pid); this.loadPlugin(this.pid);
localStorage.setItem('plugin', this.pid); localStorage.setItem("plugin", this.pid);
}, },
get chapterKeys() { get chapterKeys() {
if (this.allChapters.length < 1) return []; if (this.allChapters.length < 1) return [];
return Object.keys(this.allChapters[0]).filter(k => !['manga_title'].includes(k)); return Object.keys(this.allChapters[0]).filter(
(k) => !["manga_title"].includes(k)
);
}, },
searchChapters(query) { searchChapters(query) {
this.searching = true; this.searching = true;
this.allChapters = []; this.allChapters = [];
this.chapters = undefined; this.chapters = undefined;
this.listManga = false; this.listManga = false;
fetch(`${base_url}api/admin/plugin/list?${new URLSearchParams({ fetch(
plugin: this.pid, `${base_url}api/admin/plugin/list?${new URLSearchParams({
query: query plugin: this.pid,
})}`) query: query,
.then(res => res.json()) })}`
.then(data => { )
if (!data.success) .then((res) => res.json())
throw new Error(data.error); .then((data) => {
if (!data.success) throw new Error(data.error);
try { try {
this.mangaTitle = data.chapters[0].manga_title; this.mangaTitle = data.chapters[0].manga_title;
if (!this.mangaTitle) throw new Error(); if (!this.mangaTitle) throw new Error();
@ -90,16 +99,20 @@ const component = () => {
this.mangaTitle = data.title; this.mangaTitle = data.title;
} }
data.chapters.forEach(c => { data.chapters.forEach((c) => {
c.array = ['hello', 'world', 'haha', 'wtf'].sort(() => 0.5 - Math.random()).slice(0, 2); c.array = ["hello", "world", "haha", "wtf"]
c.date = ['4 Jun, 1989', '1 July, 2021'].sort(() => 0.5 - Math.random())[0]; .sort(() => 0.5 - Math.random())
.slice(0, 2);
c.date = [612892800000, "1625068800000"].sort(
() => 0.5 - Math.random()
)[0];
}); });
this.allChapters = data.chapters; this.allChapters = data.chapters;
this.chapters = data.chapters; this.chapters = data.chapters;
}) })
.catch(e => { .catch((e) => {
alert('danger', `Failed to list chapters. Error: ${e}`); alert("danger", `Failed to list chapters. Error: ${e}`);
}) })
.finally(() => { .finally(() => {
this.searching = false; this.searching = false;
@ -110,19 +123,20 @@ const component = () => {
this.allChapters = []; this.allChapters = [];
this.chapters = undefined; this.chapters = undefined;
this.manga = undefined; this.manga = undefined;
fetch(`${base_url}api/admin/plugin/search?${new URLSearchParams({ fetch(
plugin: this.pid, `${base_url}api/admin/plugin/search?${new URLSearchParams({
query: query plugin: this.pid,
})}`) query: query,
.then(res => res.json()) })}`
.then(data => { )
if (!data.success) .then((res) => res.json())
throw new Error(data.error); .then((data) => {
if (!data.success) throw new Error(data.error);
this.manga = data.manga; this.manga = data.manga;
this.listManga = true; this.listManga = true;
}) })
.catch(e => { .catch((e) => {
alert('danger', `Search failed. Error: ${e}`); alert("danger", `Search failed. Error: ${e}`);
}) })
.finally(() => { .finally(() => {
this.searching = false; this.searching = false;
@ -140,53 +154,64 @@ const component = () => {
} }
}, },
selectAll() { selectAll() {
$('tbody > tr').each((i, e) => { $("tbody > tr").each((i, e) => {
$(e).addClass('ui-selected'); $(e).addClass("ui-selected");
}); });
}, },
clearSelection() { clearSelection() {
$('tbody > tr').each((i, e) => { $("tbody > tr").each((i, e) => {
$(e).removeClass('ui-selected'); $(e).removeClass("ui-selected");
}); });
}, },
download() { download() {
const selected = $('tbody > tr.ui-selected').get(); const selected = $("tbody > tr.ui-selected").get();
if (selected.length === 0) return; if (selected.length === 0) return;
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => { UIkit.modal
const ids = selected.map(e => e.id); .confirm(`Download ${selected.length} selected chapters?`)
const chapters = this.chapters.filter(c => ids.includes(c.id)); .then(() => {
console.log(chapters); const ids = selected.map((e) => e.id);
this.adding = true; const chapters = this.chapters.filter((c) =>
fetch(`${base_url}api/admin/plugin/download`, { ids.includes(c.id)
method: 'POST', );
console.log(chapters);
this.adding = true;
fetch(`${base_url}api/admin/plugin/download`, {
method: "POST",
body: JSON.stringify({ body: JSON.stringify({
chapters, chapters,
plugin: this.pid, plugin: this.pid,
title: this.mangaTitle title: this.mangaTitle,
}), }),
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json",
} },
}) })
.then(res => res.json()) .then((res) => res.json())
.then(data => { .then((data) => {
if (!data.success) if (!data.success) throw new Error(data.error);
throw new Error(data.error); const successCount = parseInt(data.success);
const successCount = parseInt(data.success); const failCount = parseInt(data.fail);
const failCount = parseInt(data.fail); alert(
alert('success', `${successCount} of ${successCount + failCount} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`); "success",
}) `${successCount} of ${
.catch(e => { successCount + failCount
alert('danger', `Failed to add chapters to the download queue. Error: ${e}`); } chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`
}) );
.finally(() => { })
this.adding = false; .catch((e) => {
}); alert(
}) "danger",
`Failed to add chapters to the download queue. Error: ${e}`
);
})
.finally(() => {
this.adding = false;
});
});
}, },
thClicked(event) { thClicked(event) {
const idx = parseInt(event.currentTarget.id.split('-')[1]); const idx = parseInt(event.currentTarget.id.split("-")[1]);
if (idx === undefined || isNaN(idx)) return; if (idx === undefined || isNaN(idx)) return;
const curOption = this.sortOptions[idx]; const curOption = this.sortOptions[idx];
let option; let option;
@ -202,40 +227,58 @@ const component = () => {
option = 1; option = 1;
} }
this.sortOptions[idx] = option; this.sortOptions[idx] = option;
this.sort(this.chapterKeys[idx], option) this.sort(this.chapterKeys[idx], option);
}, },
// Returns an array of filtered but unsorted chapters. Useful when // Returns an array of filtered but unsorted chapters. Useful when
// reseting the sort options. // reseting the sort options.
get filteredChapters() { get filteredChapters() {
let ary = this.allChapters.slice(); let ary = this.allChapters.slice();
console.log('initial size:', ary.length); console.log("initial size:", ary.length);
for (let filter of this.appliedFilters) { for (let filter of this.appliedFilters) {
if (!filter.value) continue; if (!filter.value || isNaN(filter.value)) continue;
if (filter.type === 'array' && filter.value === 'all') continue; if (filter.type === "array" && filter.value === "all") continue;
console.log('applying filter:', filter); console.log("applying filter:", filter);
if (filter.type === 'string') { if (filter.type === "string") {
ary = ary.filter(ch => ch[filter.key].toLowerCase().includes(filter.value.toLowerCase())); ary = ary.filter((ch) =>
ch[filter.key]
.toLowerCase()
.includes(filter.value.toLowerCase())
);
} }
if (filter.type === 'number-min') { if (filter.type === "number-min") {
ary = ary.filter(ch => Number(ch[filter.key]) >= Number(filter.value)); ary = ary.filter(
(ch) => Number(ch[filter.key]) >= Number(filter.value)
);
} }
if (filter.type === 'number-max') { if (filter.type === "number-max") {
ary = ary.filter(ch => Number(ch[filter.key]) <= Number(filter.value)); ary = ary.filter(
(ch) => Number(ch[filter.key]) <= Number(filter.value)
);
} }
if (filter.type === 'date-min') { if (filter.type === "date-min") {
ary = ary.filter(ch => this.parseDate(ch[filter.key]) >= this.parseDate(filter.value)); ary = ary.filter(
(ch) => Number(ch[filter.key]) >= Number(filter.value)
);
} }
if (filter.type === 'date-max') { if (filter.type === "date-max") {
ary = ary.filter(ch => this.parseDate(ch[filter.key]) <= this.parseDate(filter.value)); ary = ary.filter(
(ch) => Number(ch[filter.key]) <= Number(filter.value)
);
} }
if (filter.type === 'array') { if (filter.type === "array") {
ary = ary.filter(ch => ch[filter.key].map(s => typeof s === 'string' ? s.toLowerCase() : s).includes(filter.value.toLowerCase())); ary = ary.filter((ch) =>
ch[filter.key]
.map((s) =>
typeof s === "string" ? s.toLowerCase() : s
)
.includes(filter.value.toLowerCase())
);
} }
console.log('filtered size:', ary.length); console.log("filtered size:", ary.length);
} }
return ary; return ary;
@ -258,124 +301,146 @@ const component = () => {
compare(a, b) { compare(a, b) {
if (a === b) return 0; if (a === b) return 0;
// try numbers // try numbers (also covers dates)
// this must come before the date checks, because any integer would if (!isNaN(a) && !isNaN(b)) return Number(a) - Number(b);
// also be parsed as a date.
if (!isNaN(a) && !isNaN(b))
return Number(a) - Number(b);
// try dates
if (!isNaN(this.parseDate(a)) && !isNaN(this.parseDate(b)))
return this.parseDate(a) - this.parseDate(b);
const preprocessString = (val) => { const preprocessString = (val) => {
if (typeof val !== 'string') return val; if (typeof val !== "string") return val;
return val.toLowerCase().replace(/\s\s/g, ' ').trim(); return val.toLowerCase().replace(/\s\s/g, " ").trim();
}; };
return preprocessString(a) > preprocessString(b) ? 1 : -1; return preprocessString(a) > preprocessString(b) ? 1 : -1;
}, },
fieldType(values) { fieldType(values) {
if (values.every(v => !isNaN(v))) return 'number'; // display input for number range if (values.every((v) => this.numIsDate(v))) return "date"; // 328896000000 => 1 Jan, 1980
if (values.every(v => !isNaN(this.parseDate(v)))) return 'date'; // display input for date range if (values.every((v) => !isNaN(v))) return "number";
if (values.every(v => Array.isArray(v))) return 'array'; // display select if (values.every((v) => Array.isArray(v))) return "array";
return 'string'; // display input for string searching. return "string";
}, },
get filters() { get filters() {
if (this.allChapters.length < 1) return []; if (this.allChapters.length < 1) return [];
const keys = Object.keys(this.allChapters[0]).filter(k => !['manga_title', 'id'].includes(k)); const keys = Object.keys(this.allChapters[0]).filter(
return keys.map(k => { (k) => !["manga_title", "id"].includes(k)
let values = this.allChapters.map(c => c[k]); );
return keys.map((k) => {
let values = this.allChapters.map((c) => c[k]);
const type = this.fieldType(values); const type = this.fieldType(values);
if (type === 'array') { if (type === "array") {
// if the type is an array, return the list of available elements // if the type is an array, return the list of available elements
// example: an array of groups or authors // example: an array of groups or authors
values = Array.from(new Set(values.flat().map(v => { values = Array.from(
if (typeof v === 'string') return v.toLowerCase(); new Set(
}))); values.flat().map((v) => {
if (typeof v === "string")
return v.toLowerCase();
})
)
);
} }
return { return {
key: k, key: k,
type: type, type: type,
values: values values: values,
}; };
}); });
}, },
get filterSettings() { get filterSettings() {
return $('#filter-form input:visible, #filter-form select:visible') return $("#filter-form input:visible, #filter-form select:visible")
.get() .get()
.map(i => ({ .map((i) => {
key: i.getAttribute('data-filter-key'), const type = i.getAttribute("data-filter-type");
value: i.value.trim(), let value = i.value.trim();
type: i.getAttribute('data-filter-type') if (type.startsWith("date"))
})); value = value ? Date.parse(value).toString() : "";
return {
key: i.getAttribute("data-filter-key"),
value: value,
type: type,
};
});
}, },
applyFilters() { applyFilters() {
this.appliedFilters = this.filterSettings; this.appliedFilters = this.filterSettings;
this.chapters = this.filteredChapters; this.chapters = this.filteredChapters;
}, },
clearFilters() { clearFilters() {
$('#filter-form input').get().forEach(i => i.value = ''); $("#filter-form input")
.get()
.forEach((i) => (i.value = ""));
this.appliedFilters = []; this.appliedFilters = [];
this.chapters = this.filteredChapters; this.chapters = this.filteredChapters;
}, },
mangaSelected(event) { mangaSelected(event) {
const mid = event.currentTarget.getAttribute('data-id'); const mid = event.currentTarget.getAttribute("data-id");
this.searchChapters(mid); this.searchChapters(mid);
}, },
parseDate(str) {
const regex = /([0-9]+[/\-,\ ][0-9]+[/\-,\ ][0-9]+)|([A-Za-z]+)[/\-,\ ]+[0-9]+(st|nd|rd|th)?[/\-,\ ]+[0-9]+/g;
// Basic sanity check to make sure it's an actual date.
// We need this because Date.parse thinks 'Chapter 1' is a date.
if (!regex.test(str))
return NaN;
return Date.parse(str);
},
subscribe(modal) { subscribe(modal) {
// TODO:
// - use select2
this.subscribing = true; this.subscribing = true;
fetch(`${base_url}api/admin/plugin/subscribe`, { fetch(`${base_url}api/admin/plugin/subscribe`, {
method: 'POST', method: "POST",
body: JSON.stringify({ body: JSON.stringify({
filters: this.filterSettings, filters: this.filterSettings,
plugin: this.pid, plugin: this.pid,
name: this.subscriptionName.trim() name: this.subscriptionName.trim(),
}), }),
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json",
} },
})
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
alert("success", "Subscription created");
}) })
.then(res => res.json()) .catch((e) => {
.then(data => { alert("danger", `Failed to subscribe. Error: ${e}`);
if (!data.success)
throw new Error(data.error);
alert('success', 'Subscription created');
})
.catch(e => {
alert('danger', `Failed to subscribe. Error: ${e}`);
}) })
.finally(() => { .finally(() => {
this.subscribing = false; this.subscribing = false;
UIkit.modal(modal).hide(); UIkit.modal(modal).hide();
}); });
}, },
filterTypeToReadable(type) { numIsDate(num) {
return !isNaN(num) && Number(num) > 328896000000; // 328896000000 => 1 Jan, 1980
},
renderCell(value) {
if (this.numIsDate(value))
return `<span>${moment(Number(value)).format(
"MMM D, YYYY"
)}</span>`;
const maxLength = 40;
if (value.length > maxLength)
return `<span>${value.substr(
0,
maxLength
)}...</span><div uk-dropdown>${value}</div>`;
return `<span>${value}</span>`;
},
renderFilterRow(ft) {
const key = ft.key;
let type = ft.type;
switch (type) { switch (type) {
case 'number-min': case "number-min":
return 'number (minimum value)'; type = "number (minimum value)";
case 'number-max': break;
return 'number (maximum value)'; case "number-max":
case 'date-min': type = "number (maximum value)";
return 'minimum date'; break;
case 'date-max': case "date-min":
return 'maximum date'; type = "minimum date";
default: break;
return type; case "date-max":
type = "maximum date";
break;
} }
} let value = ft.value;
} if (!value || isNaN(value)) value = "";
else if (ft.type.startsWith("date"))
value = moment(Number(value)).format("MMM D, YYYY");
return `<td>${key}</td><td>${type}</td><td>${value}</td>`;
},
};
}; };

View File

@ -114,10 +114,10 @@
<div x-show="field.type === 'date'" class="uk-grid-small" uk-grid> <div x-show="field.type === 'date'" class="uk-grid-small" uk-grid>
<div class="uk-width-1-2@s"> <div class="uk-width-1-2@s">
<input class="uk-input" placeholder="minimum date, e.g., Jan 1 1970" :data-filter-key="field.key" data-filter-type="date-min"> <input class="uk-input" type="date" placeholder="minimum date (yyyy-mm-dd)" :data-filter-key="field.key" data-filter-type="date-min">
</div> </div>
<div class="uk-width-1-2@s"> <div class="uk-width-1-2@s">
<input class="uk-input" placeholder="maximum date, e.g., Jan 1 1970" :data-filter-key="field.key" data-filter-type="date-max"> <input class="uk-input" type="date" placeholder="maximum date (yyyy-mm-dd)" :data-filter-key="field.key" data-filter-type="date-max">
</div> </div>
</div> </div>
@ -161,17 +161,7 @@
<template x-for="ch in chapters" :key="ch"> <template x-for="ch in chapters" :key="ch">
<tr class="ui-widget-content" :id="ch.id"> <tr class="ui-widget-content" :id="ch.id">
<template x-for="k in chapterKeys" :key="k"> <template x-for="k in chapterKeys" :key="k">
<td> <td x-html="renderCell(ch[k])"></td>
<template x-if="ch[k].length > 40">
<span>
<span x-text="`${ch[k].substring(0, 40)}...`"></span>
<div uk-dropdown><span x-text="ch[k]"></span></div>
</span>
</template>
<template x-if="!ch[k].length || ch[k].length <= 40">
<span x-text="ch[k]"></span>
</template>
</td>
</template> </template>
</tr> </tr>
</template> </template>
@ -201,11 +191,7 @@
</thead> </thead>
<tbody> <tbody>
<template x-for="ft in filterSettings" :key="ft"> <template x-for="ft in filterSettings" :key="ft">
<tr> <tr x-html="renderFilterRow(ft)"></tr>
<td x-text="ft.key"></td>
<td x-text="filterTypeToReadable(ft.type)"></td>
<td x-text="ft.value"></td>
</tr>
</template> </template>
</tbody> </tbody>
</table> </table>
@ -222,6 +208,7 @@
<% content_for "script" do %> <% content_for "script" do %>
<%= render_component "jquery-ui" %> <%= render_component "jquery-ui" %>
<%= render_component "moment" %>
<script src="<%= base_url %>js/alert.js"></script> <script src="<%= base_url %>js/alert.js"></script>
<script src="<%= base_url %>js/plugin-download.js"></script> <script src="<%= base_url %>js/plugin-download.js"></script>
<% end %> <% end %>