Alter filter field dropdown to show the label used in the grid

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));
            }
        });
    });
}