Add more advanced series filtering mechanism
Opened this issue · 2 comments
CollinHeist commented
Allow more complex filters based on all available Series fields.
Field Name | Field Attribute | Data Type |
---|---|---|
ID | id |
Numeric |
Name | name |
String |
Year | year |
Numeric |
Monitored | monitored |
Boolean |
List of Libraries | N/A | List |
Episode Data Source ID | data_source_id |
Nullable Numeric |
Font ID | font_id |
Nullable Numeric |
Sync ID | sync_id |
Nullable Numeric |
Card Directory | directory |
Nullable String |
Card Filename Format | card_filename_format |
Nullable String |
Enable Specials | sync_specials |
Nullable Boolean |
Skip Localized Images | skip_localized_images |
Nullable Boolean |
Has Missing Title Cards | N/A | Boolean |
The filter conditions should probably be based on the data type of the attribute, so:
- Numeric
- is less than (input)
- is less than or equal to (input)
- equals (input)
- is greater than (input)
- is greater than or equal to (input)
- String
- equals (input)
- does not Equal (input)
- contains (input)
- does not contain (input)
- starts with (input)
- does not start with (input)
- ends with (input)
- does not end with (input)
- matches (input)
- does not match (input)
- Boolean
- is true
- is false
- Nullable Boolean
- is true
- is false
- is null
- is not null
CollinHeist commented
Working Implementation
<template id="filter-template">
<div class="three fields">
<div class="field" data-label="field">
<label>Field</label>
<div class="ui selection dropdown">
<input type="hidden" name="field" onchange="updateConditions(this);">
<i class="dropdown icon"></i>
<div class="default text">Field Name</div>
<div class="menu"></div>
</div>
</div>
<div class="field" data-label="condition">
<label>Condition</label>
<div class="ui selection dropdown">
<input type="hidden" name="condition">
<i class="dropdown icon"></i>
<div class="default text">Condition Type</div>
<div class="menu"></div>
</div>
</div>
<div class="disabled field" data-label="reference">
<label>Reference</label>
<input type="text" name="reference" placeholder="Reference Value">
</div>
</div>
</template>
<div class="ui modal">
<div class="header">Filter Settings</div>
<div class="content">
<form class="ui form">
<div class="ui field">
<label>Filter Name</label>
<input type="text" placeholder="Name" value="My Filter">
</div>
<div class="ui divider"></div>
<!-- Conditions added later -->
</form>
<div class="ui labeled icon button" onclick="addFilterCondition();">
<i class="filter icon"></i>
Add Filter
</div>
</div>
<div class="actions">
<button class="ui button" onclick="serializeForm();">
Apply Filter
</button>
</div>
</div>
// Populate filters
const filterSettings = [
// name value type
['Series Name', 'name', 'string' ],
['Series Year', 'year', 'numeric' ],
['Monitored Status', 'monitored', 'boolean' ],
['Series ID', 'id', 'numeric' ],
['Sync ID', 'sync_id', 'nullable numeric'],
['Font ID', 'font_id', 'nullable numeric'],
['Card Directory', 'card_directory', 'nullable string' ],
['List of Libraries', 'libraries', 'list' ],
['Has Missing Title Cards', 'missing_cards', 'boolean', ],
].map(setting => {
return { name: setting[0], value: setting[1], type: setting[2] };
});
const filterChoices = {
'string': [
{name: 'equals', requiresInput: true},
{name: 'does not equal', requiresInput: true},
{name: 'contains', requiresInput: true},
{name: 'does not contain', requiresInput: true},
{name: 'starts with', requiresInput: true},
{name: 'does not start with', requiresInput: true},
{name: 'ends with', requiresInput: true},
{name: 'does not end with', requiresInput: true},
{name: 'matches', requiresInput: true},
{name: 'does not match', requiresInput: true},
],
'nullable string': [
{name: 'equals', requiresInput: true },
{name: 'does not equal', requiresInput: true },
{name: 'contains', requiresInput: true },
{name: 'does not contain', requiresInput: true },
{name: 'starts with', requiresInput: true },
{name: 'does not start with', requiresInput: true },
{name: 'ends with', requiresInput: true },
{name: 'does not end with', requiresInput: true },
{name: 'matches', requiresInput: true },
{name: 'does not match', requiresInput: true },
{name: 'is null', requiresInput: false},
{name: 'is not null', requiresInput: false},
],
'numeric': [
{name: 'is less than', requiresInput: true},
{name: 'is less than or equal to', requiresInput: true},
{name: 'equals', requiresInput: true},
{name: 'is greater than', requiresInput: true},
{name: 'is greater than or equal to', requiresInput: true},
],
'nullable numeric': [
{name: 'is less than', requiresInput: true },
{name: 'is less than or equal to', requiresInput: true },
{name: 'equals', requiresInput: true },
{name: 'is greater than', requiresInput: true },
{name: 'is greater than or equal to', requiresInput: true },
{name: 'is null', requiresInput: false},
{name: 'is not null', requiresInput: false},
],
'boolean': [
{name: 'is true', requiresInput: false},
{name: 'is false', requiresInput: false},
],
'nullable boolean': [
{name: 'is true', requiresInput: false},
{name: 'is false', requiresInput: false},
{name: 'is null', requiresInput: false},
{name: 'is not null', requiresInput: false},
],
'list': [
{name: 'is empty', requiresInput: false},
{name: 'is not empty', requiresInput: false},
{name: 'includes', requiresInput: true },
{name: 'does not include', requiresInput: true },
],
};
function updateConditions(obj) {
// Get the type of the newly selected filter field
// const fieldType = filterSettings.filter(fltr => fltr.value === obj.value)[0].type;
const fieldType = filterSettings.find(filter => filter.value === obj.value).type;
// Initialize associated dropdown with condition choices for this type
$(obj).closest('.fields').find('[data-label="condition"] .dropdown').dropdown({
onChange: (condition, text, $selectedItem) => {
// Enable/disable the dropdown based on the selected condition
const requiresInput = filterChoices[fieldType].find(choice => choice.name === condition).requiresInput;
$($selectedItem).closest('.fields').find('.field[data-label="reference"]').toggleClass('disabled', !requiresInput);
},
values: filterChoices[fieldType].map(choice => {
return {
name: choice.name,
value: choice.name,
selected: false,
};
}),
});
// Disable and clear reference field until condition is selected
const referenceField = $(obj).closest('.fields').find('.field[data-label="reference"]');
referenceField.toggleClass('disabled', true);
referenceField.find('input').val('');
}
const template = document.getElementById('filter-template').content;
filterSettings.forEach(filter => {
const item = document.createElement('div');
item.className = 'item'; item.dataset.value = filter.value; item.innerText = filter.name;
template.querySelector('.dropdown .menu').appendChild(item);
});
function addFilterCondition() {
document.querySelector('.modal .form').appendChild(template.cloneNode(true));
$('.dropdown').dropdown();
}
$('.modal').modal({blurring: true}).modal('show');
// Function to serialize form inputs into a list of objects
function serializeForm() {
const data = [];
// Loop through each group of fields and gather inputs
document.querySelectorAll('.modal .fields').forEach((fieldDiv) => {
// Get the values of 'name', 'condition', and 'reference' inputs
const nameInput = fieldDiv.querySelector('input[name="field"]');
const conditionInput = fieldDiv.querySelector('input[name="condition"]');
const referenceInput = fieldDiv.querySelector('input[name="reference"]');
const requiresInput = !fieldDiv.querySelector('.field[data-label="reference"]').className.includes('disabled');
// Ensure all inputs exist and retrieve their values
if (nameInput && conditionInput && referenceInput) {
const name = nameInput.value,
condition = conditionInput.value,
reference = referenceInput.value || null;
if (name && condition && (reference || !requiresInput)) {
data.push({
name: name,
condition: condition,
reference: reference
});
}
}
});
console.log(data);
return data;
}
CollinHeist commented
More Progress..
HTML Templates
<template id="blank-tab-template">
<div class="ui bottom attached tab segment">
<form class="ui form">
<div class="ui field">
<label>Filter Name</label>
<input type="text" name="filter_name" placeholder="Name" value="New Filter" oninput="updateTitle(this);">
</div>
<div class="ui divider"></div>
<!-- Conditions added later -->
</form>
<div class="ui labeled icon button" onclick="addFilterCondition(this);">
<i class="plus circle icon"></i>
Add Condition
</div>
<div class="ui right floated red icon button" onclick="deleteFilter(this);">
<i class="trash alternate outline icon"></i>
Delete Filter
</div>
</div>
</template>
<template id="filter-template">
<div class="three fields">
<div class="field" data-label="field">
<label>Field</label>
<div class="ui selection clearable dropdown">
<input type="hidden" name="field" onchange="updateConditions(this);">
<i class="dropdown icon"></i>
<div class="default text">Field Name</div>
<div class="menu"></div>
</div>
</div>
<div class="field" data-label="condition">
<label>Condition</label>
<div class="ui selection dropdown">
<input type="hidden" name="condition">
<i class="dropdown icon"></i>
<div class="default text">Condition Type</div>
<div class="menu"></div>
</div>
</div>
<div class="disabled field" data-label="reference">
<label>Reference</label>
<input type="text" name="reference" placeholder="Reference Value">
</div>
</div>
</template>
HTML Modal
<div class="ui modal">
<div class="header">Filter Settings</div>
<div class="content">
<div class="ui top attached tabular wrapping menu">
<!-- Tab selector added later -->
<div class="item add-tab" data-tab="add" onclick="addTab();"><i class="plus circle blue icon"></i></div>
</div>
<!-- Tabs added later -->
</div>
<div class="actions">
<button class="ui blue button" onclick="serializeAllFilters();">
<i class="check icon"></i>
Apply Filter
</button>
</div>
</div>
JavaScript
<script type="text/javascript">
/**
* "Live" update the title of the tab containing this filter name input.
**/
function updateTitle(inputElement) {
const tabName = inputElement.closest('.tab.segment').dataset.tab;
document.querySelector(`.modal .tabular.menu .item[data-tab="${tabName}"]`).innerText = inputElement.value;
}
// Add a new tab to the filter modal
function addTab(filter=null) {
// Determine tab number - check for existence in case a middle tab was deleted
let tabNumber = document.querySelectorAll('.modal .tabular.menu .item').length - 1;
while (document.querySelector(`.modal .tabular.menu .item[data-tab="tab${tabNumber}"]`)) {
tabNumber += 1;
}
// Add new tab selector to menu, just before add tab item
const $tabHeader = $('<div>', {
class: 'item',
'data-tab': 'tab' + tabNumber,
text: filter?.name || 'New Filter',
});
$('.modal .tabular.menu .item.add-tab').before($tabHeader);
// Add blank tab
const newTab = document.getElementById('blank-tab-template').content.cloneNode(true);
newTab.querySelector('.tab').dataset.tab = 'tab' + tabNumber;
if (filter) {
newTab.querySelector('input[name="filter_name"]').value = filter.name;
}
document.querySelector('.modal .content').appendChild(newTab);
$('.modal .tabular.menu .item').tab();
const tabs = document.querySelectorAll('.modal .content .tab.segment');
return tabs[tabs.length - 1];
}
function deleteFilter(deleteButton) {
const tabID = deleteButton.closest('.tab.segment').dataset.tab;
document.querySelectorAll(`.modal [data-tab="${tabID}"]`).forEach(tab => tab.remove());
$('.modal .tabular.menu .item').tab('change tab', 'tab0');
}
</script>
<script type="text/javascript">
const filterSettings = [
// name value type
['Card Directory', 'directory', 'nullable string' ],
['Series Name', 'name', 'string' ],
['Series Year', 'year', 'numeric' ],
['Monitored Status', 'monitored', 'boolean' ],
['Series ID', 'id', 'numeric' ],
['Sync ID', 'sync_id', 'nullable numeric'],
['Font ID', 'font_id', 'nullable numeric'],
['Episode Data Source ID', 'data_source_id', 'nullable numeric'],
['List of Libraries', 'libraries', 'list' ],
['Has Missing Title Cards', 'missing_cards', 'boolean', ],
['Card Filename Format', 'card_filename_format', 'nullable string' ],
['Enable Specials', 'sync_specials', 'nullable boolean'],
['Localized Image Rejection', 'skip_localized_images', 'nullable boolean'],
['List of Translations', 'translations', 'list' ],
['Match Titles', 'match_titles', 'boolean' ],
['Auto-Split Titles', 'auto_split_titles', 'boolean' ],
['Per-Season Assets', 'use_per_season_assets', 'boolean' ],
['Image Source Priority', 'image_source_priority', 'list' ],
['Emby Database ID', 'emby_id', 'nullable string' ],
['IMDb Database ID', 'imdb_id', 'nullable string' ],
['Jellyfin Database ID', 'jellyfin_id', 'nullable string' ],
['Sonarr Database ID', 'sonarr_id', 'nullable string' ],
['TMDb Database ID', 'tmdb_id', 'nullable numeric'],
['TVDb Database ID', 'tvdb_id', 'nullable numeric'],
['TVRage Database ID', 'tvrage_id', 'nullable string' ],
['Font Color', 'font_color', 'nullable string' ],
['Font Title Case', 'font_title_case', 'nullable string' ],
['Font Size', 'font_size', 'nullable numeric'],
['Font Kerning', 'font_kerning', 'nullable numeric'],
['Font Stroke Width', 'font_stroke_width', 'nullable numeric'],
['Font Interline Spacing', 'font_interline_spacing', 'nullable numeric'],
['Font Interword Spacing', 'font_interword_spacing', 'nullable numeric'],
['Font Vertical Shift', 'font_vertical_shift', 'nullable numeric'],
['Card Type', 'card_type', 'nullable string' ],
['Hide Season Text', 'hide_season_text', 'nullable boolean'],
['Season Title List', 'season_titles', 'nullable list' ],
['Hide Episode Text', 'hide_episode_text', 'nullable boolean'],
['Episode Text Format', 'episode_text_format', 'nullable string' ],
['Unwatched Card Style', 'unwatched_style', 'nullable string' ],
['Watched Card Style', 'watched_style', 'nullable string' ],
['Extras', 'extras', 'nullable list' ],
].map(setting => {
return { name: setting[0], value: setting[1], type: setting[2] };
});
const filterChoices = {
'string': [
{name: 'equals', requiresInput: true},
{name: 'does not equal', requiresInput: true},
{name: 'contains', requiresInput: true},
{name: 'does not contain', requiresInput: true},
{name: 'starts with', requiresInput: true},
{name: 'does not start with', requiresInput: true},
{name: 'ends with', requiresInput: true},
{name: 'does not end with', requiresInput: true},
{name: 'matches', requiresInput: true},
{name: 'does not match', requiresInput: true},
],
'nullable string': [
{name: 'equals', requiresInput: true },
{name: 'does not equal', requiresInput: true },
{name: 'contains', requiresInput: true },
{name: 'does not contain', requiresInput: true },
{name: 'starts with', requiresInput: true },
{name: 'does not start with', requiresInput: true },
{name: 'ends with', requiresInput: true },
{name: 'does not end with', requiresInput: true },
{name: 'matches', requiresInput: true },
{name: 'does not match', requiresInput: true },
{name: 'is null', requiresInput: false},
{name: 'is not null', requiresInput: false},
],
'numeric': [
{name: 'is less than', requiresInput: true},
{name: 'is less than or equal to', requiresInput: true},
{name: 'equals', requiresInput: true},
{name: 'is greater than', requiresInput: true},
{name: 'is greater than or equal to', requiresInput: true},
],
'nullable numeric': [
{name: 'is less than', requiresInput: true },
{name: 'is less than or equal to', requiresInput: true },
{name: 'equals', requiresInput: true },
{name: 'is greater than', requiresInput: true },
{name: 'is greater than or equal to', requiresInput: true },
{name: 'is null', requiresInput: false},
{name: 'is not null', requiresInput: false},
],
'boolean': [
{name: 'is true', requiresInput: false},
{name: 'is false', requiresInput: false},
],
'nullable boolean': [
{name: 'is true', requiresInput: false},
{name: 'is false', requiresInput: false},
{name: 'is null', requiresInput: false},
{name: 'is not null', requiresInput: false},
],
'list': [
{name: 'is empty', requiresInput: false},
{name: 'is not empty', requiresInput: false},
{name: 'includes', requiresInput: true },
{name: 'does not include', requiresInput: true },
],
'nullable list': [
{name: 'is empty', requiresInput: false},
{name: 'is not empty', requiresInput: false},
{name: 'includes', requiresInput: true },
{name: 'does not include', requiresInput: true },
{name: 'is null', requiresInput: false},
{name: 'is not null', requiresInput: false},
],
};
function updateConditions(obj) {
// Disable and clear reference field until condition is selected
const referenceField = $(obj).closest('.fields').find('.field[data-label="reference"]');
referenceField.toggleClass('disabled', true);
referenceField.find('input').val('');
// No value means the field was cleared
if (!obj.value) {
// Clear condition dropdown
$(obj).closest('.fields').find('[data-label="condition"] .ui.dropdown').dropdown({
values: []
});
return;
}
// Get the type of the newly selected filter field
const fieldType = filterSettings.find(filter => filter.value === obj.value).type;
// Initialize associated dropdown with condition choices for this type
$(obj).closest('.fields').find('[data-label="condition"] .ui.dropdown').dropdown({
onChange: (condition, text, $selectedItem) => {
// Enable/disable the dropdown based on the selected condition
const requiresInput = filterChoices[fieldType].find(choice => choice.name === condition).requiresInput;
$($selectedItem).closest('.fields').find('.field[data-label="reference"]').toggleClass('disabled', !requiresInput);
},
placeholder: 'Condition Type',
values: filterChoices[fieldType].map(choice => {
return {
name: choice.name,
value: choice.name,
selected: false,
};
}),
});
}
const template = document.getElementById('filter-template').content;
filterSettings.forEach(filter => {
const item = document.createElement('div');
item.className = 'item'; item.dataset.value = filter.value; item.innerText = filter.name;
template.querySelector('.dropdown .menu').appendChild(item);
});
function addFilterCondition(addButton, removeLabels=true) {
const newFields = document.getElementById('filter-template').content.cloneNode(true);
if (removeLabels) {
newFields.querySelectorAll('.field label').forEach(label => label.remove());
}
// Add to page, initialize dropdowns
addButton.closest('.tab.segment').querySelector('.form').appendChild(newFields);
// document.querySelector('.modal .form').appendChild(newFields);
$('.modal .form .dropdown').dropdown();
}
$('.modal').modal({blurring: true}).modal('show');
</script>
<script type="text/javascript">
// Function to serialize form inputs into a list of objects
function serializeAllFilters() {
const filters = [];
let activeTab = null;
// Serialize each tab
document.querySelectorAll('.modal .content .tab.segment').forEach((tab, index) => {
const data = {
name: tab.querySelector('input[name="filter_name"]').value,
conditions: [],
};
// Update active tab number if needed
if (activeTab === null && tab.classList.contains('active')) {
activeTab = index;
}
// Loop through each group of fields and gather inputs
tab.querySelectorAll('.fields').forEach((fieldDiv) => {
// Get the values of all input fields
const fieldInput = fieldDiv.querySelector('input[name="field"]');
const conditionInput = fieldDiv.querySelector('input[name="condition"]');
const referenceInput = fieldDiv.querySelector('input[name="reference"]');
// An input is required if the reference field is not disabled
const requiresInput = !fieldDiv.querySelector('.field[data-label="reference"]').className.includes('disabled');
// Ensure all inputs exist and retrieve their values
if (fieldInput && conditionInput && referenceInput) {
const field = fieldInput.value,
condition = conditionInput.value,
reference = referenceInput.value || null;
if (field && condition && (reference || !requiresInput)) {
data.conditions.push({ field, condition, reference, });
}
}
});
filters.push(data);
});
console.log({ filters, activeTab });
return { filters, activeTab };
}
</script>
<script type="text/javascript">
// Eventually will be passed via Jinja or AJAX call
const existingFilters = [
{
name: 'Missing Cards (no Star)',
conditions: [
{ name: "name", condition: "does not contain", reference: "Star" },
{ name: "missing_cards", condition: "is true", reference: null },
],
},
{
name: 'Unmonitored and Missing Cards',
conditions: [
{ name: "monitored", condition: "is false", reference: null },
{ name: "missing_cards", condition: "is true", reference: null },
]
}
];
existingFilters.forEach(filter => {
// Add blank tab for this filter
const tab = addTab(filter);
// Add each condition
filter.conditions.forEach((condition, index) => {
// Add new filter row for this condition
const newFields = document.getElementById('filter-template').content.cloneNode(true);
// Remove labels for all but the first condition
if (index > 0) {
newFields.querySelectorAll('.field label').forEach(label => label.remove());
}
// Add to page so dropdowns can be populated
tab.querySelector('.form').appendChild(newFields);
// Populate fields
$(tab).find('.field[data-label="field"] div.dropdown').last().dropdown('set selected', condition.name);
$(tab).find('.field[data-label="condition"] div.dropdown').last().dropdown('set selected', condition.condition);
if (condition.reference !== null) {
$(tab).find('.field[data-label="reference"] input').last().val(condition.reference);
}
});
});
</script>