CLASSIC Only JS solution
One of the things that has always bugged me is when I have changed a field label on a grid column, the filter selection then doesn’t reflect the displayed field label, it always shows the field name in the table. This is confusing for users because they don’t match, so they have to know that Account Number is the same thing as Account. There are many reasons to change the label in different views, so this is a common occurrence.
Thankfully my good friend Claude has come to the rescue - the JS below will scan the header of any grid and adjust the filter names, the names as they show in filter pills, etc. There is only one case it cant catch - if the column is not initially displayed (for example its a blank column and hidden as such) then any filter on it will not be adjusted until it is displayed. But that is a 1% case, 99% of the time it works as expected. Better than 0% without.
So here is the code - should work for anyone, on any grid view.
/************************************************************************
KNACK FIX: Make Knack's filter UI show your CUSTOM column labels
instead of the original table field name - in BOTH the "Add Filters"
field dropdown AND the little filter "pills" shown above a grid once
a filter is applied.
------------------------------------------------------------------
THE PROBLEM
------------------------------------------------------------------
Knack lets you give a field a custom display label per-view (in the
Builder: click a column header > rename it). So your grid might show
a column called "Last Comment" - but the field's real underlying
name in the table might be something completely different, like
"Display Comment".
Everywhere else in Knack, your custom label is what users see. But
the filter UI stubbornly ignores it in two places:
1. The "Add Filters" panel's field dropdown always shows the
ORIGINAL table field name.
2. Once a filter is applied, the little "pill" shown above the grid
(e.g. "Display Comment is not blank") also uses the original
name, not your custom label.
This has apparently annoyed people for years - I couldn't find
anyone who'd solved it, on Knack's forum or elsewhere.
------------------------------------------------------------------
WHY THIS IS FIXABLE AT ALL
------------------------------------------------------------------
The filter dropdown is a completely plain, native HTML <select>:
<select class="field select" name="field">
<option value="field_203">Type</option>
<option value="field_344">Display Comment</option>
</select>
The VALUE ("field_344") is what Knack actually uses to build the
filter. The TEXT between the tags is just what's displayed. They're
completely independent, so relabelling the text never touches how
filtering actually works.
The filter pill turns out to have TWO different markup variants
(confirmed by inspecting a live app) - but both include a dedicated
<span class="field"> holding JUST the field name, separate from the
operator and value:
<!-- Variant A -->
<span class="kn-filter-original-text">
<span class="field">Date Due</span> <span class="operator">is today</span> <span class="value">VALUE</span>
</span>
<!-- Variant B - no wrapper span, sits directly in the edit link -->
<a class="kn-edit-filter">
<span class="field">Display comment</span> <span class="operator">is not blank</span> <span class="value">VALUE</span>
</a>
Because there's a dedicated span just for the field name, relabelling
it is a simple, safe text swap on that one element - no need to
find-and-replace within a larger sentence, and no risk of disturbing
the operator/value parts.
------------------------------------------------------------------
WHERE THE "REAL" CUSTOM LABEL COMES FROM
------------------------------------------------------------------
No separate list to maintain - your custom label already lives in
the grid's own column header:
<th class="field_344">Last Comment</th>
This is read fresh, live, every time it's needed. A small in-memory
cache remembers the last label seen for each field, because some
grids have a "hide empty columns" setting that removes a column's
<th> from the page entirely when every visible row has no value in
it (e.g. right after a filter narrows the results) - without the
cache, the pill would revert to the original name the instant that
happens, even though your custom label hasn't actually changed. The
one gap this can't cover: a column that's ALREADY hidden on the very
first page load, before its header has ever been seen even once
this session - a narrow edge case, in practice rarely hit.
------------------------------------------------------------------
A GOTCHA WORTH KNOWING IF YOU ADAPT THIS
------------------------------------------------------------------
The filter pills sit inside a wrapper whose id is the view's own id
with "_filters" appended (e.g. "view_226_filters"). If you try to
find the "nearest ancestor whose id starts with view_" from a pill,
you'll match that WRAPPER first, not the actual grid view - both ids
start with "view_". Derive the real view id by stripping the known
"_filters" suffix from the wrapper's own id instead of guessing via
ancestor traversal.
------------------------------------------------------------------
HOW IT'S TRIGGERED
------------------------------------------------------------------
Both the dropdown and pills are built/rebuilt by Knack at specific
moments, not present in the page permanently - so rather than
watching the whole page constantly, this listens for the actions
known to (re)build them:
- Clicking "Add filters" itself
- Clicking "+" to add another filter condition row
- Clicking an EXISTING pill to edit it (its real edit link is
a.kn-edit-filter - an earlier version of this script guessed at
a different, non-existent class here and silently missed this
whole interaction path for a while)
- Any normal view/records render (catches pills present on page
load from a previously-saved or preset filter)
------------------------------------------------------------------
HOW TO USE THIS
------------------------------------------------------------------
Paste this whole block into your app's JavaScript settings. No
configuration needed - works generically across every grid.
TUNING: if fields still show their original name after testing, the
most likely cause is 200ms not being quite long enough for Knack to
finish building the relevant DOM on a particular page. Try increasing
the setTimeout delays below (300-400ms) before assuming the logic
itself is wrong.
Tested on a live production Knack app, including working through
several real edge cases (two different pill markup variants, the
view-id ancestor gotcha above, and the hide-empty-columns interaction)
rather than just the simple happy path. Happy for anyone to use,
adapt, or improve this - no attribution required, though always
appreciated! Thank Claude for its services ....
*************************************************************************/
// Remembers which view (grid) the filter UI was last interacted with for.
window.knackFilterRelabel_lastViewKey = null;
// field_key -> last known custom label, so a field's label survives its
// column being temporarily removed from the DOM (e.g. by "hide empty
// columns").
window.knackFilterRelabel_labelCache = window.knackFilterRelabel_labelCache || {};
// --- Trigger 1: opening the "Add Filters" panel ---
$(document).on('click', '.kn-add-filter', function () {
var $view = $(this).closest('[id^="view_"]');
if ($view.length) {
window.knackFilterRelabel_lastViewKey = $view.attr('id');
}
setTimeout(relabelKnackFilters, 200);
});
// --- Trigger 2: adding another filter condition row inside an
// already-open panel (each row gets its own field dropdown) ---
$(document).on('click', '#add-filter-link', function () {
setTimeout(relabelKnackFilters, 200);
});
// --- Trigger 3: clicking an EXISTING pill to edit it ---
$(document).on('click', 'a.kn-edit-filter', function () {
var $filtersContainer = $(this).closest('[id$="_filters"]');
if ($filtersContainer.length) {
window.knackFilterRelabel_lastViewKey = $filtersContainer.attr('id').replace(/_filters$/, '');
}
setTimeout(relabelKnackFilters, 200);
});
// --- Trigger 4: normal view renders - catches pills present on page
// load from a previously-saved or preset filter ---
$(document).on('knack-view-render.any knack-records-render.any', function () {
setTimeout(relabelFilterPills, 150);
});
function relabelKnackFilters() {
relabelFilterDropdown();
relabelFilterPills();
}
//----------------------------------------------------------------
// Current custom label for a field, read live from whichever grid
// it's a column on - with the hide-empty-columns fallback described
// above.
//----------------------------------------------------------------
function getCurrentColumnLabel(viewKey, fieldKey) {
var $header = $('#' + viewKey + ' thead th.' + fieldKey);
if ($header.length) {
var text = $header.text().trim(); // icons render via CSS ::before,
// not text nodes, so this is
// already clean
if (text) {
window.knackFilterRelabel_labelCache[fieldKey] = text;
return text;
}
}
return window.knackFilterRelabel_labelCache[fieldKey] || null;
}
//----------------------------------------------------------------
// PART 1: relabel the field dropdown's <option> elements.
//----------------------------------------------------------------
function relabelFilterDropdown() {
var viewKey = window.knackFilterRelabel_lastViewKey;
if (!viewKey) return;
// Populate the cache from whatever headers are currently live.
$('#' + viewKey + ' thead th[class*="field_"]').each(function () {
var match = this.className.match(/field_\d+/);
if (match) getCurrentColumnLabel(viewKey, match[0]);
});
$('select.field.select[name="field"]').each(function () {
$(this).find('option').each(function () {
var fieldKey = $(this).val(); // untouched - the real filter value
var label = getCurrentColumnLabel(viewKey, fieldKey);
if (label) $(this).text(label); // only the visible text changes
});
});
}
//----------------------------------------------------------------
// PART 2: relabel the active-filter pills shown above the grid.
//----------------------------------------------------------------
function relabelFilterPills() {
$('[id$="_filters"] li.kn-tag-filter[class*="kn-filter-field_"]').each(function () {
var $li = $(this);
var match = this.className.match(/kn-filter-field_(\d+)/);
if (!match) return;
var fieldKey = 'field_' + match[1];
// .field can sit either inside a .kn-filter-original-text
// wrapper, or directly inside the edit link, depending on the
// pill variant - search anywhere in the <li> rather than
// assuming one specific parent structure.
var $fieldSpan = $li.find('.field').first();
if (!$fieldSpan.length) return;
var currentSpanText = $fieldSpan.text().trim();
// Derive the real view id from the filters WRAPPER's own id by
// stripping the "_filters" suffix - do not try to find "nearest
// ancestor starting with view_" from here, since the wrapper
// itself matches that too and sits closer than the real view.
var $filtersContainer = $li.closest('[id$="_filters"]');
if (!$filtersContainer.length) return;
var viewKey = $filtersContainer.attr('id').replace(/_filters$/, '');
var customLabel = getCurrentColumnLabel(viewKey, fieldKey);
if (!customLabel || customLabel === currentSpanText) return; // nothing to
// change, or
// already correct
// (also makes this
// function safely
// idempotent)
var nativeName = currentSpanText; // hasn't been touched yet on this
// element, so this genuinely is
// the original name
$fieldSpan.text(customLabel); // only this dedicated span changes -
// operator/value spans untouched
// The <li> and its edit link sometimes carry a title attribute
// with the full sentence too - fix those the same way if present.
[$li, $li.find('a.kn-edit-filter')].forEach(function (el) {
var $el = $(el);
var title = $el.attr('title');
if (title && title.indexOf(nativeName) !== -1) {
$el.attr('title', title.replace(nativeName, customLabel));
}
});
});
}