verbb / navigation

A Craft CMS plugin to create navigation menus for your site.
Other
90 stars 22 forks source link

Point a navigation entry to a "bookmark" global #382

Open tremby opened 7 months ago

tremby commented 7 months ago

What are you trying to do?

We have a few URLs which are used throughout the app. They might very infrequently change, but if they do they should always stay in sync. We've done this with "globals". A navigation entry is one of the places we want to point to one of these URLs.

What's your proposed solution?

If the navigation plugin were to accept a plain text global as another source of "external URL", that would work for us.

But it might be nice as a bonus if it'd support a link field global so it could be flexible in type. Or a similar type provided by the Navigation plugin.

Additional context

Another possibility I've been considering is to treat the navigation entry as the main source of truth for the URL, and so instead of fetching it for other contexts from globals, fetch it from the navigation. This might work in theory but I'd need some kind of reliable identifier with which to find it. I don't want to use an ID since this would change if the link gets deleted and recreated. I don't want to use the title because that might change. Using slug might make sense but it only seems to be null or "1" on the nodes I have, and I don't see a field to manually set it.

engram-design commented 7 months ago

That would certainly present a unique situation for the Navigation plugin. Currently, we only accept relating elements themselves, whereas in your case, you want to pick an element field within a global, and use that value. That might prove to be a technically challenging one, due to the different type of element fields. Then, we'd also need to look at other fields like the 4 populate link fields, and I'm sure there's more field types.

If we're talking about being able to use shorthand Twig in the "Custom URL" node type, then that's something we can certainly look into. It's not the most novice-friendly option, but certainly okay for developers.

You could also create your own node types to allow you to pick your global fields as well.

As for the comment about the node being the source of truth, it might not be the best fit, as you say there's no real identifying trait for a node other than the ID. However, you could add a custom field for a "handle" to your node, and then search based on that custom field in an element query for a node (craft.node.myHandleField('someHandle')) . I wouldn't use the slug as that's been internally repurposed to refer to the siteId for the node.

tremby commented 7 months ago

I'm actually not using twig at all -- we're using Craft in headless mode, and have a separate application which fetches data via GraphQL.

I think a custom node type may do what I want. I didn't realize that feature exists. I'm trying it out now... It looks like the idea is that I make a new node type, then in the navigation settings I add some custom fields, and set them to only appear if the node type matches my custom one, right?

At the moment if I choose my new "bookmark" node type I don't see my custom field in the sidebar:

image

("This is the settings HTML" is output by my node type for now in getSettingsHtml while I get a grip on what is possible).

Even though that field is set to required, it's letting me create the node without it.

Is there a way to have my custom field appear in this sidebar? Or am I meant to add something in my getSettingsHtml method for that? If so, can you give some guidance?

If I then click to edit that node, I do see the custom field I added, along with the getModalHtml output:

image

Trying to save the node without setting my custom field here is correctly giving an error saying it's required.

Letting it save without it in the first place opens the possibility of bad data. Is that a bug or have I missed a step, or is there something I'm not understanding?

tremby commented 7 months ago

So with the above issues still standing, I have this working. My PHP class looks like this.

<?php

namespace mynamespace;

use verbb\navigation\base\NodeType;

class BookmarkNavigationNode extends NodeType
{
    public static function displayName(): string
    {
        return "Bookmark";
    }

    public static function hasTitle(): bool
    {
        return true;
    }

    public static function hasUrl(): bool
    {
        return false;
    }

    public static function hasNewWindow(): bool
    {
        return true;
    }

    public static function hasClasses(): bool
    {
        return true;
    }

    public static function getColor(): string
    {
        return "#a0522d";
    }

    public function getUrl(): ?string
    {
        try {
            // This is an "entries" field which accepts maximum 1 entry
            $bookmark = $this->node->getFieldValue("bookmark")->one();
        } catch (\craft\errors\InvalidFieldException $e) {
            // I don't know if catching this is overkill
            return null;
        }
        if (!$bookmark) return null;
        try {
            // This is a "link" field (sebastianlenz/linkfield)
            $link = $bookmark->getFieldValue("linkDefault");
        } catch (\craft\errors\InvalidFieldException $e) {
            // I don't know if catching this is overkill
            return null;
        }
        if (!$link) return null;
        return $link->url;
    }
}

And in my main module file I have init code very similar to the example given at https://verbb.io/craft-plugins/navigation/docs/developers/extensibility#node-types

So I can get the URL directly off these nodes, or I can drill deeper into the custom field if I want the specifics, which sometimes I do. For example, if it's an entry, I want to know its site ID so I can tell for sure whether it's the same site, because if so I can do a SPA-style navigation to it rather than a full page navigation.

engram-design commented 7 months ago

Sorry, I may have explained that incorrectly. Custom fields are for all node types, and are managed in the Navigation settings, under Node Fields. You won't see then in the right-hand sidebar when you add a node, but once added you can add your content there.

You could add your custom field to the sidebar, but it does depend on what sort of field it might be. For example the site node type adds a select field, that's for the siteId attribute on the node type, but you can do a similar thing for custom fields.

{{ forms.textField({
    label: 'Custom Field' | t('app'),
    id: 'myCustomFieldHandle',
    name: 'fields[myCustomFieldHandle]',
}) }}

That's just for a text field, but as I mentioned, it depends what sort of custom field you want to include.

But, as you're using a custom node type, you can technically add whatever bits of content you want, and you've got control over the template when adding a new node, so include any sorts of fields your node type requires.

tremby commented 7 months ago

Sorry, I may have explained that incorrectly. Custom fields are for all node types, and are managed in the Navigation settings, under Node Fields. You won't see then in the right-hand sidebar when you add a node, but once added you can add your content there.

It's not exactly for all node types since I can use the condition to only show it on one given node type. But yeah, I do not like how it's not showing it in the sidebar, and allowing the node to be saved without the required field being filled.

You could add your custom field to the sidebar, but it does depend on what sort of field it might be.

What if it's an "entries" field, like I want to use? (I said before I was doing this via globals, but I'm toying with the idea of bookmarks being entries.) Your example looks simple, but does just printing a field there somehow magically also handle saving and retrieving the data?

engram-design commented 7 months ago

Yeah, good call on any required fields not being provided when created. We'll probably need to change the node-creation sidebar to show the element slideout instead. We just wanted to keep the process of creating nodes simple, particularly when trying to add multiple elements in a single go.

That example code was just to put in your settings.twig file for the sidebar for when adding the node. But because the node type class doesn't know about the field layout, or the nav it's being used in, you can't just call something like nav.getCustomField('fieldHandle').getInputHtml() which would be neat.

If it's an element field, you can use Craft's elementSelect() call.

{{ forms.elementSelectField({
    label: 'Custom Field' | t('app'),
    id: 'myCustomFieldHandle',
    name: 'fields[myCustomFieldHandle]',
    elementType: 'craft\\elements\\Entry',
}) }}

As for saving the data, the entire form for creating a node is serialized and used for the node. Retrieving isn't really an issue, as you're creating the node from scratch. Editing the node in the element slide-out will be a different case and that'll just be done automatically, based on the navigation's node type fields.

tremby commented 7 months ago

I'm giving that a try. It couldn't find my template and so I added this to my module:

        Event::on(
            View::class,
            View::EVENT_REGISTER_CP_TEMPLATE_ROOTS,
            function(RegisterTemplateRootsEvent $event) {
                $event->roots["myapp"] = __DIR__ . "/templates";
            }
        );

For forms to exist I needed to start my template with an import, which I found in the existing node templates:

{% import "_includes/forms" as forms %}

It seems these form helper macros are undocumented, but after some web searches I found that I can add the option require: true. That deals with the UI, but Navigation doesn't seem to actually care if it's empty. Not sure if that's a bug. But I can work around that:

    public function beforeSaveNode(bool $isNew): bool
    {
        $bookmark = $this->node->data["fields"]["bookmark"];
        if (!$bookmark) throw new \Exception("A bookmark must be chosen.");
        return true;
    }

(It's unclear what the bool I'm supposed to return is for, by the way. It seems to save the node whether I return true or false.)

One place where I'm stuck now is limiting the available options to entries in a particular section. I found an old SO post which suggests I can do criteria: { section: ["bookmarks"] } but when I try this I get an internal server error, and in the logs I see an SQL error saying "column lft does not exist", which seems bizarre. Same when not wrapped in an array.

I noted that the field edit screen in Craft actually calls them "sources" rather than "sections" so I found this other old SO post which has set {sources: ["section:ID"]}. Trying that with my section ID 22 gave me no SQL error, but seems to limit the options to nothing at all. I tried the handle name "bookmarks" too, no better.

Any idea?

My workaround for now is not not ideal; it's to just throw an error if the wrong type was chosen, by putting this in the beforeSaveNode:

        $bookmarkId = $bookmarks[0];
        $bookmark = \craft\elements\Entry::find()->siteId("*")->section("bookmarks")->id($bookmarkId)->one();
        if (!$bookmark) throw new \Exception("A bookmark entry from the bookmarks section must be chosen.");

As much as I don't like this, there's a bigger problem:

The entry I chose is not actually saved in the custom field. I can pick an entry in the sidebar, hit save, the validation I wrote accepts it (and I can verify via debugger that it has the correct ID in memory), but then when I go to edit the node afterwards, the field is empty.

To give a bit more context, the custom field has handle bookmark, and is a standard "Entries" field which limits selections to the "bookmarks" section (it's called "sources" in that view), and has a limit of 1 relation. My template looks like this.

{% import "_includes/forms" as forms %}

{{ forms.elementSelectField({
    label: "Bookmark" | t("app"),
    id: "bookmark",
    name: "fields[bookmark]",
    elementType: "craft\\elements\\Entry",
    required: true,
}) }}
tremby commented 7 months ago

I've discovered why I shouldn't continue exploring late at night. I was totally overthinking this.

I can simply use an "entry" node, and point to my bookmark entry. It doesn't need to be a special type limited to bookmark entries. I don't really need Navigation to resolve the nested link in that bookmark entry and present it in the node's url field; I can detect the linked entry type as being a bookmark in my app and do the resolution transparently there.

I do still think solutions to the issues I had above would be valuable in general.

I'll leave it up to you whether to leave this open or close it.

Thanks for all you help.

engram-design commented 6 months ago

Yeah, sorry for the vague templating guidance, that's getting into Craft native territory (their forms macros), and properly bootstrapping a module with the template roots stuff.

But you've certainly given me pause for thought about making the add-node behaviour consistent with how you edit it. That way, things like this would be much easier to address. I may even make the adding screen configurable separate to the "Node Fields" for editing, just to allow people to customise (and minimise) the fields used for adding a new node.

I believe that would've helped your use-case by adding a custom Entries field, being able to add that to the "Node Fields" layout, configuring that to the "Add Node" screen and it'll all just work. Of course, with a bit more documentation as well.