Jump to content

first real world module: a few questions


Recommended Posts

Hi,

I've just made my first real world module 🙂

It's about importing pages from an external API, that uses a special "push" URL where the module listens on. If this special URL gets called, then it does its magic.

The module basically replaces a custom field and saves it on the page(s) with the incoming data from the foreign API. And on before saving module configuration, we try to retrieve the credential challenge from the other's side.

So, here are my questions:

  1. In reference to this new feature article: https://processwire.com/blog/posts/pw-3.0.173/ How can we set HTTP response codes like 400, 404, 500, etc. in an JSON API? And how can we set other response headers like "Content-Type: application/json+problem"?
  2.  I'm using the new configuration class, i. e. like so:
<?php

namespace ProcessWire;

class GsSandboxConfig extends ModuleConfig
{
    /**
     * default options for our configurations, if not set
     */
    private const DEFAULT_OPTIONS = [
        'api_key' => '',
        'secret' => '',
        'testpage' => 'testpage-policy',
        'template' => self::TPL_NAME,
        'parent' => 'home',
    ];

    /**
     * It defines the ProcessWire HTML-like Inputfield* class and its properties, the config names should match the DEFAULT_OPTIONS constant.
     */
    private const CONFIG_FIELD_PROPS = [
        'api_key' => ['type' => 'InputfieldText', 'props' => ['label' => 'API Key', 'columnWidth' => 50, 'notes' => 'The API key from your account.', 'required' => true]],
        'secret' => ['type' => 'InputfieldText', 'props' => ['label' => 'Secret', 'columnWidth' => 50, 'notes' => 'The secret (PLEASE DO NOT CHANGE IT)', 'required' => false]],
        'testpage' => ['type' => 'InputfieldText', 'props' => ['label' => 'site name testpage (Englich)', 'columnWidth' => 50, 'notes' => 'Enter a different name from "testpage" here, if applicable', 'required' => false]],
        'template' => ['type' => 'InputfieldText', 'props' => ['label' => 'Template for the pages', 'columnWidth' => 50, 'notes' => 'Enter the template to be used by the text pages here.', 'required' => false]],
        'parent' => ['type' => 'InputfieldText', 'props' => ['label' => 'Parent page for the pages', 'columnWidth' => 50, 'notes' => 'Enter the parent page from which the text pages for children\'s pages should be.', 'required' => false]],
    ];

    public const SANDBOX_ENDPOINT_URL = '/sandbox/';

    /**
     * needed for the server API Call to identify the version that we are using
     */
    public const SANDBOX_VERSION_NUMBER = '1.0.3';

    public const SANDBOX_REPLACEMENT_VAR = 'sandbox_text';

    public const TPL_NAME = 'legal-sandbox';

    public const LOG_CHANNEL_FILE = 'sandbox';

    public const TEMPLATE_FIELDGROUP_NAME = 'funny-tpl';

    public function getDefaults()
    {
        return self::DEFAULT_OPTIONS;
    }

    public function getInputfields()
    {
        $fields = parent::getInputfields();

        foreach ($this->getDefaults() as $configPropName => $propValue) {
            if (isset(self::CONFIG_FIELD_PROPS[$configPropName])) {
                $f = $this->wire('modules')->get(self::CONFIG_FIELD_PROPS[$configPropName]['type']);
                $f->attr('name', $configPropName);
                $f->attr('value', $propValue);
                foreach (self::CONFIG_FIELD_PROPS[$configPropName]['props'] as $fieldPropName => $fieldPropValue) {
                    $f->{$fieldPropName} = $fieldPropValue;
                }
                $fields->add($f);
            }
        }

        return $fields;
    }
}
  • Is it possible to define readonly / not editable fields in module config?
  • Is it possible to build a select based on all existing templates or by a filter to avoid to let the user input something that might be wrong?
  • Corresponding to the select menu for templates: Can we also filter out templates where pages or fields not allowed?

3. It was hard for me to create a new page with no parent page - it should be on the top level (so that it's possible to have URLs like this: www.domain.com/funnypage/). I've tried many different approaches. However, this one works. At the first call, it will fail with an Exception, but on the second call it works (reproducible):

    public function createTextPage(string $name): Page
    {
        $config = $this->wire('modules')->getConfig('GsSandbox');
        $pages = $this->wire('pages');
        $db = $this->wire('database');
        $tpl = $this->wire('templates');

        $template = $tpl->get($config['template']);
        if (!$template instanceof Template) {
            $template = $tpl->get('basic-page');
        }
        $parent = $pages->findOne('name=' . $config['parent']);
        if ($parent instanceof NullPage) {
            $parent = $pages->findOne('name=basic-page');
        }

        $p = $pages->new([
            'template' => $template,
            'parent' => '/',
            'title' => $name,
            'name' => $name,
            'path' => '/' . $name,
        ]);

        $f = $this->wire('fields')->get(GsSandboxConfig::SANDBOX_REPLACEMENT_VAR);
        $p->{GsSandboxConfig::SANDBOX_REPLACEMENT_VAR} = $f;
        $p->save();

        return $p;
    }

On the first try, I'll get this Exception (with stack trace):

Method Page::localPath does not exist or is not callable in this context, Stack Trace:
#0 /wire/core/Page.php(1512): Wire->___callUnknown()
#1 /wire/core/Wire.php(419): Page->___callUnknown()
#2 /wire/core/WireHooks.php(968): Wire->_callMethod()
#3 /wire/core/Wire.php(484): WireHooks->runHooks()
#4 /wire/core/Wire.php(487): Wire->__call()
#5 /wire/modules/PagePaths.module(451): Wire->__call()
#6 /wire/modules/PagePaths.module(82): PagePaths->updatePagePaths()
#7 /wire/core/WireHooks.php(1094): PagePaths->hookPageMoved()
#8 /wire/core/Wire.php(484): WireHooks->runHooks()
#9 /wire/core/PagesEditor.php(787): Wire->__call()
#10 /wire/core/PagesEditor.php(478): PagesEditor->savePageFinish()
#11 /wire/core/Pages.php(840): PagesEditor->save()
#12 /wire/core/Wire.php(416): Pages->___save()
#13 /wire/core/WireHooks.php(968): Wire->_callMethod()
#14 /wire/core/Wire.php(484): WireHooks->runHooks()
#15 /wire/core/PagesEditor.php(117): Wire->__call()
#16 /wire/core/Pages.php(975): PagesEditor->add()
#17 /wire/core/Wire.php(416): Pages->___new()
#18 /wire/core/WireHooks.php(968): Wire->_callMethod()
#19 /wire/core/Wire.php(484): WireHooks->runHooks()
#20 /site/modules/GsSandbox/GsSandbox.module(154): Wire->__call()
#21 /site/modules/GsSandbox/GsSandboxHooker.php(315): GsSandbox->createTextPage()
#22 /site/modules/GsSandbox/GsSandboxHooker.php(228): GsSandboxHooker->writeToPage()
#23 /site/modules/GsSandbox/GsSandboxHooker.php(105): GsSandboxHooker->importFunnyPage()
#24 /wire/core/WireHooks.php(1094): GsSandboxHooker->urlListener()
#25 /wire/core/Wire.php(484): WireHooks->runHooks()
#26 /wire/modules/Process/ProcessPageView.module(265): Wire->__call()
#27 /wire/modules/Process/ProcessPageView.module(116): ProcessPageView->renderNoPage()
#28 /wire/core/Wire.php(416): ProcessPageView->___execute()
#29 /wire/core/WireHooks.php(968): Wire->_callMethod()
#30 /wire/core/Wire.php(484): WireHooks->runHooks()
#31 /webserver/document-root/public/index.php(55): Wire->__call()
#32 {main}

4. How can we log messages that the user will see in the admin?

I'm currently doing this, but this only logs in the separated text file and somehow in an unfindable "session":

private function sendError(string $msg): void
    {
        $this->wire('log')->save(
            GsSandboxConfig::LOG_CHANNEL_FILE,
            'An error occured during init of the module: ' . $msg
        );
        $this->session->message('An error occured during init of the module: ' . $msg);
    }

5. How is it possible to write on HTML editor (admin only) multi-language pages with the above method? Because we'd like to use link alternate language meta tag to reference to the corresponding language versions of this page. I know that you need your own language mapping, since it's totally free to define it ProcessWire, but an example would be helpful and highly appreciated!

So this is basically my hooker class (a dedicated class where all hooks are placed and the business logic happens, stripped down to the necassary show case parts), which is added as singleton in the Module::init method:

<?php

declare(strict_types=1);

namespace ProcessWire;

require_once __DIR__.DIRECTORY_SEPARATOR.'GsSandbox.module';
require_once __DIR__.DIRECTORY_SEPARATOR.'GsSandboxConfig.php';

class GsSandboxHooker extends Wire
{
    private static ?GsSandboxHooker $instance = null;

    private ?Module $mainModule = null;

    private bool $wasInSaveHook = false;

    private const TO_VERIFY_PAGES = [
        'funnypage',
        'anotherpage'
    ];

    public static function getInstance(Module $module): GsSandboxHooker
    {
        if (null === self::$instance) {
            self::$instance = new GsSandboxHooker($module);
            self::$instance->setMainModule($module);
        }

        return self::$instance;
    }

    public function addOurHooks(): void
    {
        $this->addHook(GsSandboxConfig::SANDBOX_ENDPOINT_URL, $this, 'urlListener');
        $this->addHook('Modules::saveConfig', $this, 'onBeforeSaveConfiguration');
    }


    public function urlListener(HookEvent $event): array
    {
        $input = $this->wire('input');

        if(!$this->validate()) {
            $this->log("[IMPORT FEHLGESCHLAGEN]");
            exit;
        }

        $data = ['status' => 200, 'message' => 'Dokumente erfolgreich importiert'];
        try {
           // ...
        } catch (\Throwable $e) {
            header("HTTP/1.1 500 Server Error");
            header('Content-Type: application/json');
            $data = [
                'status' => 500,
                'message' => $e->getMessage(),
                'code' => $e->getCode(),
                'file' => $e->getFile(),
            ];
        }

        return $data;
    }

    public function onBeforeSaveConfiguration(HookEvent $event)
    {
        $class = $event->arguments(0);
        $assocData = $event->arguments(1);

        if ($class !== $this->mainModule->className || $this->wasInSaveHook()) {
            return;
        }

        $apiKey = strval($assocData['api_key']);
        if (!$apiKey) {
            $this->sendError('No API key specified, abort!');
            return;
        }

        $this->setWasInSaveHook(true);

        // update parent and template on each, if necessary:
        $this->verifyAndUpdatePages($assocData);


        // business logic here, save your stuff, request the API, etc.


        // save back everything:
        $event->arguments(1, $assocData);
        $this->saveModuleConfig($assocData);
    }

    private function saveModuleConfig(array $data): void
    {
        $this->wire('modules')->saveConfig('GsSandbox', $data);
    }

    private function verifyAndUpdatePages(array $assocData, array $toVerifyPages = self::TO_VERIFY_PAGES): void
    {
        $pages = $this->wire('pages');
        $db = $this->wire('database');
        $tpl = $this->wire('templates');

        foreach ($toVerifyPages as $p) {
            $shouldSave = false;
            $page = $pages->findOne('name=' . $p);

            if ($page instanceof NullPage) {
                if (!$this->writeToPage($p, 'init')) {
                    continue;
                }
                $page = $pages->findOne('name=' . $p);
            }

            if ($page->template !== $assocData['template']) {
                $innerTpl = $tpl->get($assocData['template']);
                if ($innerTpl instanceof Template) {
                    $page->template = $innerTpl;
                    $shouldSave = true;
                }
            }

            // TODO: bugfix parent page association:
//            if ($page->parent->name !== $assocData['parent']) {
//                $parentPage = $pages->findOne('name=' . $db->escapeTableCol($assocData['parent']));
//                if ($parentPage instanceof Page) {
//                    $page->parent = $parentPage;
//                    $shouldSave = true;
//                }
//            }

            if ($shouldSave) {
                $page->save();
            }
        }
    }

    public function setMainModule(Module $module): void
    {
        $this->mainModule = $module;
    }

    public function getMainModule(): ?Module
    {
        return $this->mainModule;
    }

    public function wasInSaveHook(): bool
    {
        return $this->wasInSaveHook;
    }

    public function setWasInSaveHook(bool $wasInSaveHook): void
    {
        $this->wasInSaveHook = $wasInSaveHook;
    }
}

A few questions to this:

6. At installation of this module the on before configuration saving hook gets fired, otherwise I couldn't explain why it creates the page twice. Is it possible to exclude also this call in my hook?

7. When we retrieve the new configuration values BEFORE it saves, why we can't manipulate it for saving? I need the saveModuleConfig approach, otherwise it doesn't get saved.

 

Any help to this or tips would be highly appreciated.

PS: We're using ProcessWire 3.0.229 on PHP 8.2.24

Thanks in advice 🙂

Link to comment
Share on other sites

Well... pretty much... 😉


Some spontaneous remarks:

1. Did you try php method header()?

2. For readonly fields maybe create a disabled field ($field->attr('disabled'), but some browsers may not send data for it, so ensure an initial value).
It should be possible to create and add a select field where you collect all templates and create an option for it.

3. Does the template you try to create a page with contain a file field or is your custom field a file field? Maybe the error message according to "localPath" refers to the page id's path in directory "assets/files" which does not exist unless you save a page (the first time). So maybe you should save your new page and then add / set other things which would rely on a path in "assets/files".

4. In modules I use...
$this->error("Error message", \ProcessWire\Notice::log);
$this->message("Message", \ProcessWire\Notice::log);

5. Do you mean how to create a multi language site where editors enter wysiwyig text or other fields in different languages? Well, that's a big topic for itself... Like / see https://processwire.com/docs/multi-language-support/multi-language-urls/

6. Maybe you should post the code of your module so one can see how it all works together... e.g. when and how your hooks are really integrated.

6./7. In your method addOurHooks() you call $this->addHook() without telling when to apply the hook (before or after). The default is "after".
Maybe the Inputfield::processInput would also provide an entry point to manipulate input (field).

Link to comment
Share on other sites

On 11/5/2024 at 5:44 PM, webdecker said:

1. Did you try php method header()?

I think it's not good to send the headers that early. But I'll give it a try

On 11/5/2024 at 5:44 PM, webdecker said:

2. For readonly fields maybe create a disabled field ($field->attr('disabled'), but some browsers may not send data for it, so ensure an initial value).
It should be possible to create and add a select field where you collect all templates and create an option for it.

I've tried readonly and disabled with ->attr(...) method and direct object property, both doesn't work, it's still editable.

On 11/5/2024 at 5:44 PM, webdecker said:

3. Does the template you try to create a page with contain a file field or is your custom field a file field? Maybe the error message according to "localPath" refers to the page id's path in directory "assets/files" which does not exist unless you save a page (the first time). So maybe you should save your new page and then add / set other things which would rely on a path in "assets/files".

As I read the the stack trace, it fails at this line in the module class, because this is line 154 in GsSandbox.module:

$template = $tpl->get('basic-page');

... you can find the whole code of this method in my entry post.

 

On 11/5/2024 at 5:44 PM, webdecker said:

6./7. In your method addOurHooks() you call $this->addHook() without telling when to apply the hook (before or after). The default is "after".
Maybe the Inputfield::processInput would also provide an entry point to manipulate input (field).

Yes thank you, that was the issue: Now I don't need to save it separately.

Link to comment
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
 Share

  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...