EasyForm - filter vocabulary query based on input

I have an Easyform with two select fields. I would like the second field to be filtered based on the selected value of the first field. Both use plone.app.z3cform.widgets.select.AjaxSelectFieldWidget.

Looking at the select2 pattern, I should be able to use onSelected() somehow. But I am having trouble finding out how this works... When selecting the second field, I can see an XHR to @@getVocabulary - my objective is to change the query parameter here.

Another approach I tried, was to add a change listener in my theme's custom js, but I can't seem to find the element that is supposed to trigger this.

Update: this gets me the token in my custom js. Still would be cleaner to use onSelected()

    $('input[id=form-widgets-brand_path_mdb]').change( function() {
        console.log($(this).val());

    });

Any hints are much appreciated...

Basically you get a value from the select2 pattern on change like this:

$("#my-pat-select2-field").on("change", (e) => {
    console.log(e.target.value);
});

And you get the pattern options inside the change callback like this:

const $main_select = $("#main-select-field");

$main_select.on("change", (e) => {
    const pattern = e.target["pattern-select2"];
    // depending if you have a multiselect field you have to split the value by separator
    const vals = $(e.target).val().split(pattern.options.separator);
    for(const val of vals) {
        // do something with your selected values
    }
});

Now it gets a little "hacky", because you have to change the option.vocabularyUrl from your child field and reinitialize the pattern with the new url:

const $main_select = $("#main-select-field");
const $child_select = $("#child-select-field");
const base_url = $("body").data("baseUrl");

$main_select.on("change", async (e) => {
    // this gets the instantinated pattern from the DOM
    const pattern = e.target["pattern-select2"];
    // depending if you have a multiselect field you have to split the value by separator
    const vals = $(e.target).val().split(pattern.options.separator);

    for(const val of vals) {
        // change the vocabulary query here
    }

    // jQuery also has an instance of the pattern code
    const child_select_pattern = $child_select.data("pattern-select2")
    child_select_pattern.options.vocabularyUrl = `${base_url}/@@getVocabulary?${your_new_criteria}`;
    // reinitialize with new URL
    await child_select_pattern.init();
});

As I just said, this is a quick and untested prototype, but may provide an approach to a solution.

1 Like

Not as hacky as creating a cookie and reading it in my vocabulary call, as I ended up doing! Thanks @petschki

Hmmm... Almost funny haha.. :face_with_monocle:

When I hardcode the new vocabularyurl to be exactly as before,

const new_criteria = "name=pnz.intranet.ProductFilterVocabulary&field=product_paths"

I run into a traceback that involves datagridfield, even when no DGF is used in this from.

2024-10-17 12:10:29,245 ERROR   [Zope.SiteErrorLog:17][waitress-3] AttributeError: http://localhost:8888/Plone/de/p-force/stapel-export/@@view/@@getVocabulary
Traceback (innermost last):
  Module ZPublisher.WSGIPublisher, line 181, in transaction_pubevents
  Module ZPublisher.WSGIPublisher, line 391, in publish_module
  Module ZPublisher.WSGIPublisher, line 285, in publish
  Module ZPublisher.mapply, line 98, in mapply
  Module Products.PDBDebugMode.wsgi_runcall, line 60, in pdb_runcall
  Module plone.app.content.browser.vocabulary, line 155, in __call__
  Module plone.app.content.browser.vocabulary, line 316, in get_vocabulary
  Module collective.z3cform.datagridfield.row, line 151, in validate
  Module collective.z3cform.datagridfield.row, line 112, in validate
  Module pnz.intranet.patches.plone_app_dexterity_permissions, line 58, in dx_field_permission_checker_validate
AttributeError: No such field: 'product_paths'
[9] > /usr/local/Plone6/src/pnz.intranet/src/pnz/intranet/patches/plone_app_dexterity_permissions.py(58)dx_field_permission_checker_validate()
-> raise AttributeError(f"No such field: {field_name}")

Will do some more digging...

(edit) Found:

const base_url = $("body").data("baseUrl"); does not include 'view/'

http://localhost:8888/Plone/de/p-force/stapel-export/view/@@getVocabulary?name=pnz.intranet.ProductFilterVocabulary&field=product_paths&query=&page_limit=10&page=1&_=1729159196124

Are you sure that was the reason? the traceback says something different ...

Background: if your vocabulary factory is not in this list (plone.app.content/plone/app/content/browser/vocabulary.py at master · plone/plone.app.content · GitHub) you will have to provide your own IFieldPermissionChecker adapter with your logic to give permission to this vocabulary.

The /view isn't anywhere in the default select2 patterns vocabularyUrl (like subjects for example) ...

Looks like it, this works fine:

http://localhost:8888/Plone/de/p-force/stapel-export/view/@@getVocabulary?name=pnz.intranet.ProductFilterVocabulary&field=product_paths&target_language=it&brand_path=/mdb/it/marche/pnz-produkte-it&query=&page_limit=10&page=1&_=1729165112900

Here is my wip based on your suggestions:

        
        const $main_select = $('input[id^=form-widgets-brand_path]');
        const $child_select = $('input[id=form-widgets-product_paths]');
        
        const base_url = $("body").data("baseUrl");
        
        const default_criteria = "name=pnz.intranet.ProductFilterVocabulary&field=product_paths";
        const filter_criteria = "";
        
        $main_select.on("change", async (e) => {
            // this gets the instantiated pattern from the DOM
            const pattern = e.target["pattern-select2"];
            // depending if you have a multiselect field you have to split the value by separator
            const vals = $(e.target).val().split(pattern.options.separator);
    
            for(const val of vals) {
                // change the vocabulary query here
                var filter_criteria="&target_language=it&brand_path=/mdb/it/marche/pnz-produkte-it";
            }

            // jQuery also has an instance of the pattern code
            const child_select_pattern = $child_select.data("pattern-select2")
            child_select_pattern.options.vocabularyUrl = `${base_url}/view/@@getVocabulary?${default_criteria}${filter_criteria}`;
            // reinitialize with new URL
            await child_select_pattern.init();
        });

Thank you @petschki your help was invaluable (again) :sweat_smile:

Here is what works for me:

const $main_select = $('input[id^=form-widgets-brand_path]');
const $child_select = $('input[id=form-widgets-product_paths]');

// why?  $child_select.data("pattern-select2") is undefined
const child_vocabulary_url = $child_select.data("pat-select2").vocabularyUrl;
let filter_criteria = "";

$main_select.on("change", async (e) => {
    // this gets the instantiated pattern from the DOM
    const pattern = e.target["pattern-select2"];
    // depending if you have a multiselect field you have to split the value by separator
    const vals = $(e.target).val().split(pattern.options.separator);

    for(const val of vals) {
        // change the vocabulary query here
        const lang = val.split('/')[2];
        filter_criteria="&target_language="+ lang +"&brand_path="+ val;
    }

    // jQuery also has an instance of the pattern code
    const child_select_pattern = $child_select.data("pattern-select2")
    child_select_pattern.options.vocabularyUrl = `${child_vocabulary_url}${filter_criteria}`;
    // reinitialize with new URL
    await child_select_pattern.init();
});

and the vocabulary makes a rest call to our Plone 5.1 instance

@provider(IVocabularyFactory)
def ProductFilterVocabulary(context):
    """ create a customer dependent products vocabulary
        using our theme js to add extra filter parameters to the vocabularyURL
    """
    terms = []
    request = getRequest()

    brand_path = request.form.get('brand_path','')
    target_language = request.form.get('target_language','de')
    title_filter = request.form.get('query', '')
    
    if brand_path:
        # do the actual REST call
        log.info( 'ProductFilterVocabulary %s %s' % (brand_path, target_language)) 

        query = {
            'b_size': 500,
            'metadata_fields':'getPath', # results token value
            'portal_type': 'produkte',
            'sort_on': 'sortable_title',
            'Language': target_language,
            'kundenname_verweis': brand_path,
        }
    
        rest_results = get_rest_session_results(
            url=search_endpoint,
            target_language=target_language,
            query=query)
            
        if rest_results:
            results = rest_results.get('items', {})
            terms = [
                SimpleTerm(
                    title=result['title'],
                    value=result['getPath']
                )
                for result in results
                if title_filter.lower() in result['title'].lower()
            ] 

    data = SimpleVocabulary(terms)

    return data

Note: when using filtered results, make sure that the validator receives a query that includes the results you selected in the form. Otherwise wou wil get a "Wrong Contained Type" error :man_facepalming:t3:, and might be chasing your tail for days trying to debug your already working js.

i.e. my default results include the first 500 entries out of many more, my filter allowed me to select entries outside of that range ==> Wrong Contained Type