Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Autocomplete] Allow passing extra options to the autocomplete fields #1322

Merged

Conversation

jakubtobiasz
Copy link
Contributor

Q A
Bug fix? no
New feature? yes
Issues n/a
License MIT

This PR still needs some tweaks and tests, but I want to validate my idea. Currently, if we pass anything as a third argument in the ->add() method, it is only used on the form render, and is fully ignored during the AJAX call.
CleanShot 2023-12-03 at 12 36 59

While implementing UX's Autocomplete in Sylius, I wanted to complete the following user story

Given I want to edit a taxon
When I want to assign a parent taxon to it
And I check a list of available taxa
Then I should see all taxa except the edited one

So basically, I have to pass a current taxon's ID to the autocomplete's query. As we know, currently it's not possible. So after contacting @weaverryan, I decided to implement a mechanism similar to the one from Live Components.

When you pass an array of options as a 3rd argument, you can use a special extra_options key, which is an array consisting scalars/arrays/nulls.

Next, when the form is rendered, I get these values, calculate a checksum for them, then pass them through json_encode and base64_encode functions. In the end, I glue them to the url values in the \Symfony\UX\Autocomplete\Form\AutocompleteChoiceTypeExtension::finishView method.

So, basically with the following configuration:
CleanShot 2023-12-03 at 12 48 51

we end up with the following HTML code:
CleanShot 2023-12-03 at 12 49 36

I decided to "glue" the extra_options to the URL, as I didn't have to deal with JS. Of course, I do not exclude a chance to refactor it, as a whole method should be refactored anyway.

Finally, the controller decodes the data, checks the checksum and passes the values to the autocomplete's form.

@alexander-schranz
Copy link
Contributor

alexander-schranz commented Dec 9, 2023

As I'm currently facing a similar issue in recreating some more complex Selection Type which is doing similar things like autocomplete. I wanted to share it here also here. https://gist.github.com/alexander-schranz/b1cb8843bc54dc368001375fe6998e61#file-mycontactchoicetype-php-L39 In my case I did also go with providing a list of options, so the loading part will have possibility todo such things. But instead of duplicating the options I just provide a list of which options should be forwarded (string[], instead of array<string, mixed>`. So I don't have to duplicate the value of the option inside the extra_options, item_loader_options in my case.

@alexander-schranz
Copy link
Contributor

alexander-schranz commented Dec 14, 2023

I try currently some magic to find out automatically my extra_options without have the need to define them in my custom Selection Type wanted to share it here, not sure if its too much magic:

    public function buildView(FormView $view, FormInterface $form, array $options): void
    {
        $extraOptions = [];
        $formType = $form->getConfig()->getType();

        $innerType = $formType->getInnerType();
        $defaultOptionsResolver = new OptionsResolver();
        $innerType->configureOptions($defaultOptionsResolver);
        $definedOptions = $defaultOptionsResolver->getDefinedOptions();
        foreach ($defaultOptionsResolver->getMissingOptions() as $missingOption) {
            $defaultOptionsResolver->remove($missingOption);
        }
        // Form, ChoiceType options which we keep always in mind and are important for form widget rendering e.g. required, disabled, ...
        foreach ($options['extra_options'] as $extra_option) {
            $definedOptions[] = $extra_option;
        }

        $defaultOptions = $defaultOptionsResolver->resolve([]);
        foreach ($options as $key => $value) {
            if (!\is_scalar($value)) {
                // ignore all options which are non-scalar values
                continue;
            }

            if (!\in_array($key, $definedOptions)) {
                // we ignore all options which were not defined specifically for the inner type
                continue;
            }

            if (isset($defaultOptions[$key]) && $defaultOptions[$key] === $value) {
                // we ignore all options which have the same value as the default value
                continue;
            }

            $extraOptions[$key] = $value;
        }

        $view->vars['extra_options'] = $extraOptions;
    }
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'extra_options' => ['required', 'disabled'],
        ]);
    }

What could also be used to avoid a seperate extra_options is the $resolver->getRequiredOptions(); used as extra_options, and so via $resolver->setRequired('some_value'); can be defined what is required:

    public function buildView(FormView $view, FormInterface $form, array $options): void
    {
        $extraOptions = [];
        $formType = $form->getConfig()->getType();

        $innerType = $formType->getInnerType();
        $defaultOptionsResolver = new OptionsResolver();
        $requiredOptions = $defaultOptionsResolver->getRequiredOptions();
        foreach ($defaultOptionsResolver->getMissingOptions() as $missingOption) {
            $defaultOptionsResolver->remove($missingOption);
        }

        $defaultOptions = $defaultOptionsResolver->resolve([]);
        foreach ($options as $key => $value) {
            if (!\is_scalar($value)) {
                // ignore all options which are non-scalar values
                continue;
            }

            if (!\in_array($key, $requiredOptions)) {
                // we ignore all options which are not required
                continue;
            }

            if (isset($defaultOptions[$key]) && $defaultOptions[$key] === $value) {
                // we ignore all options which have the same value as the default value
                continue;
            }

            $extraOptions[$key] = $value;
        }

        $view->vars['extra_options'] = $extraOptions;
    }


namespace Symfony\UX\Autocomplete\Calculator;

final class ChecksumCalculator implements ChecksumCalculatorInterface
Copy link
Contributor

@alexander-schranz alexander-schranz Dec 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my own selection type I did instead of using a checksum go with the UriSigner but ignore all query parameters which I don't know, so maybe we could do here the avoid own checksum calculator:

if (!$this->uriSigner->check(
    // ignore unrelated query parameters as they can be search filters which are allowed
    $request->getSchemeAndHttpHost() . $request->getPathInfo() . '?_config=' . \rawurlencode($request->query->get('_config')) . '&_hash=' . \rawurlencode($request->query->get('_hash')),
)) {
    throw new AccessDeniedHttpException('Invalid signature.');
}

/**
 * @var array{
 *     options: array<string, mixed>,
 *     name: string,
 *     theme: string,
 * }|false
 */
$config = \json_decode(\base64_decode($request->query->get('_config'), true), true, 512, \JSON_THROW_ON_ERROR);

@jakubtobiasz jakubtobiasz force-pushed the autocomplete-with-extra-options branch 6 times, most recently from 3e13fc6 to 74a9577 Compare December 20, 2023 19:35
@smnandre
Copy link
Member

Do you want some help/feedback/other on this ?

@jakubtobiasz
Copy link
Contributor Author

jakubtobiasz commented Dec 21, 2023

Hi @smnandre,
I've refactored one of two places I marked as "needs to be refactored". I have to write some functional tests for it, and... basically it will be ready to be fully reviewed.

I'll let you know once ready to be reviewed :D. It should be ready today :D.

@smnandre
Copy link
Member

Great! Good luck then :)

@jakubtobiasz jakubtobiasz marked this pull request as ready for review December 24, 2023 07:24
@jakubtobiasz
Copy link
Contributor Author

I guess I haven't missed anything, and it's ready to be reviewed 👍🏻. AFAIR LiveComponent build failures are not related to my PR.

{
$this->sortKeysRecursively($data);

return base64_encode(hash_hmac('sha256', json_encode($data), $this->secret, true));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe use some lighter hash algo?

Suggested change
return base64_encode(hash_hmac('sha256', json_encode($data), $this->secret, true));
return base64_encode(hash_hmac('xxh128', json_encode($data), $this->secret, true));

Ref: https://php.watch/articles/php-hash-benchmark

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would the base64 still be necessary ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I guess the base64_encode is kind of a leftover before I started using hash_hmac (or I used it to get a shorter string) 🤔. We can remove it and increase the string length from 44 to 64.

In terms of the algorithm:
CleanShot 2023-12-25 at 13 42 35@2x

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The question here would be how we treat that data, for me it's checksum which is not a password but should be kinda of cryptographic, but it doesn't need to be perfect as a password and it doesn't need to be as safe as it is.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, you're right. I thought (idk why :D) that we encrypt data here, but the data is not encrypted at all.

So the answer here is simple, I used sha256, because that's exactly the same way as we calculate the checksum in the LiveComponent. I'm open to changing it, I just copied the code from the LiveComponent and didn't bother to think whether sha256 is the correct one here :D.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I'm thinking about extracting checksum calculating inside the LiveComponent component, and using it in the autocomplete component. Autocomplete depends on the LiveComponent so it might make sense.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would make perfect sense to me.

Feel free to do it in a second/later PR if you want to release this one first... in all maner this should be "internal" so there is no BC involved there :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was 100% sure LiveComponent is required by the Autocomplete, but it is not.

So, for now, I leave the Calculator where it lives. I can mark the class as internal but I'll leave the interface as it is, to allow people to create their own implementation of checksum calculator. If anyone finds they need a lighter version of hash algorithm, it'll make sense to create their own.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but I'll leave the interface as it is,

I'd prefer to remove the interface. I can't see a legit reason to need to write your own. If there is a problem with our class, then we want them to tell us so we can fix it :)

Copy link
Contributor Author

@jakubtobiasz jakubtobiasz Feb 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@weaverryan I've removed it!

@smnandre
Copy link
Member

Somewhere in the future documentation, should we add a small warning about the visibility of extra_options ? Something like "the given extra options can be retrieved and therefore should not contain sensible or personal information"

/**
* Interface for classes that will have an "autocomplete" endpoint exposed with a possibility to pass additional options.
*/
interface OptionsAwareEntityAutocompleterInterface extends EntityAutocompleterInterface
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather have separate Interfaces, what do you think ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do like creating "subtype" interfaces like this, but it's not a thing I'd fight for :D. I'm open for changing it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No strong opinion on this.

i'm often more concerned about "what could happen later" than i probably should... i'm working on it but habits are hard to change :)

That's why i ask / raise questions but would never "require" changes :))

@jakubtobiasz jakubtobiasz changed the title [Autocomplete] Add a service for calculating a checksum for an array … [Autocomplete] Allow passing extra options to the autocomplete fields Dec 28, 2023
Copy link
Member

@weaverryan weaverryan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few comments, but this is really great! Could you also bootstrap docs for this? Thanks!

{
$this->sortKeysRecursively($data);

return base64_encode(hash_hmac('sha256', json_encode($data), $this->secret, true));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but I'll leave the interface as it is,

I'd prefer to remove the interface. I can't see a legit reason to need to write your own. If there is a problem with our class, then we want them to tell us so we can fix it :)

private function getExtraOptions(Request $request): array
{
if (!$request->query->has(self::EXTRA_OPTIONS)) {
return [];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we used Symfony's UriSigner instead of the checksum? If extra options exist, we sign the URL when generating it and passing it to the frontend. Here, we validate the signed URL.

@weaverryan
Copy link
Member

Friendly ping :) - I'd love to get this across the finish line. Thanks!

@weaverryan weaverryan added Status: Needs Work Additional work is needed Feature New Feature labels Jan 29, 2024
@jakubtobiasz
Copy link
Contributor Author

Hi @weaverryan,
I'll try to get back to it this weekend 😅.

@jakubtobiasz jakubtobiasz force-pushed the autocomplete-with-extra-options branch from 2b6b26c to db61002 Compare February 4, 2024 21:17
@carsonbot carsonbot added Status: Needs Review Needs to be reviewed and removed Status: Needs Work Additional work is needed labels Feb 4, 2024
@jakubtobiasz jakubtobiasz force-pushed the autocomplete-with-extra-options branch from 1c21c2c to 0ff4d2d Compare February 4, 2024 21:22

namespace Symfony\UX\Autocomplete\Checksum;

/** @internal */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/** @internal */
/**
* @author .... ?
*
* @internal
*/

(same thing on OptionsAware..Interface ?)

@@ -11,6 +11,8 @@
- Added `tom-select/dist/css/tom-select.bootstrap4.css` to `autoimport` - this
will cause this to appear in your `controllers.json` file by default, but disabled
see.
- Allow passing `extra_options` key in an array passed as a `3rd` argument of the `->add()` method.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compared to symfony/form changelog, i'd write it maybe more like

  • Add extra_options to XXX and YYY types (those options are .... to ...)

@carsonbot carsonbot added Status: Reviewed Has been reviewed by a maintainer and removed Status: Needs Review Needs to be reviewed labels Feb 5, 2024
@weaverryan weaverryan force-pushed the autocomplete-with-extra-options branch from 0ff4d2d to 308daef Compare February 8, 2024 11:40
@weaverryan
Copy link
Member

Thanks for your hard work on this Jacob!

@weaverryan weaverryan merged commit 9e086b1 into symfony:2.x Feb 8, 2024
6 checks passed
@jakubtobiasz jakubtobiasz deleted the autocomplete-with-extra-options branch February 8, 2024 12:16
@arlenreyb
Copy link

Not sure if this is bad etiquette, but if anyone came to this thread looking for how to use this new feature, an example of how to use it is in this thread, it's just hidden by GitHub. It's right here.

@weaverryan
Copy link
Member

Oh yea, it looks like we missed creating a docs PR. @jakubtobiasz are you able to create a quick docs PR for this feature?

kbond added a commit that referenced this pull request Jul 31, 2024
…e Ajax-powered autocomplete (jakubtobiasz)

This PR was merged into the 2.x branch.

Discussion
----------

[LiveComponent] Document how to use `extra_options` in the Ajax-powered autocomplete

| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | n/a
| Issues        | requested in #1322 (comment)
| License       | MIT

> Time spent on this PR has been sponsored by [Commerce Weavers](https://commerceweavers.com/) ♥️.

Commits
-------

aab8892 [LiveComponent] Document how to use `extra_options` in the Ajax-powered autocomplete
symfony-splitter pushed a commit to symfony/ux-autocomplete that referenced this pull request Jul 31, 2024
…e Ajax-powered autocomplete (jakubtobiasz)

This PR was merged into the 2.x branch.

Discussion
----------

[LiveComponent] Document how to use `extra_options` in the Ajax-powered autocomplete

| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | n/a
| Issues        | requested in symfony/ux#1322 (comment)
| License       | MIT

> Time spent on this PR has been sponsored by [Commerce Weavers](https://commerceweavers.com/) ♥️.

Commits
-------

aab88929 [LiveComponent] Document how to use `extra_options` in the Ajax-powered autocomplete
smnandre added a commit that referenced this pull request Dec 23, 2024
… attributes (pierredup)

This PR was squashed before being merged into the 2.x branch.

Discussion
----------

[Autocomplete] Reset TomSelect when updating controller attributes

| Q             | A
| ------------- | ---
| Bug fix?      | yes
| New feature?  | no
| Issues        | Fix #1500
| License       | MIT

When using remote data for Autocomplete, the mutation observer is not started, so the select cannot be reset when some options change (E.G when limiting the options returned after selecting other options using extra_options from #1322).

Instead, we start the mutation observer when using remote data. When the observer runs and checks if the options have changes, we explicitly set the `areOptionsEquivalent` to `true`, since the page will just render an empty `select`. We also check if any of the `data-autocomplete` attributes changes and forces a reset (E.G using extra_options, the `data-autocomplete-url` value can be different when using extra options, so we reset the select when this attribute changes).

This fixes only one of the issues from #1500, I'll investigate the other issues and create separate PRs.

Commits
-------

d8fb3fb [Autocomplete] Reset TomSelect when updating controller attributes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Feature New Feature Status: Reviewed Has been reviewed by a maintainer
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants