Jump to content

FireWire

Members
  • Posts

    351
  • Joined

  • Last visited

  • Days Won

    25

Everything posted by FireWire

  1. Hello! Mailchimp is a popular email marketing platform and one that is used more often than any alternative by my clients. After having to work with embedding HTML and creating generic Textarea fields to output to pages, I wanted to create a better experience and robust form management. Mailchimp for Formbuilder is a module that adds a new form processing Action to form configuration in FormBuilder. It's intended to be easy to use for both ProcessWire developers and end users while being very configurable. This module lets you do more in ProcessWire faster with great feature parity. Here are some module features: Create new FormBuilder forms that collect Mailchimp subscription signups Add Mailchimp subscription signup abilities to any existing FormBuilder form Each FormBuilder form is configured independently so you can create forms for specific Mailchimp needs Compatible with any type of field that can be configured in Mailchimp Manage Mailchimp settings right where you configure other FormBuilder form settings Stop pasting Mailchimp form embed codes and make subscribing to Mailchimp lists easy using your forms styled the way you want No need to create redirect pages for successful signups, let FormBuilder handle the form submission experiences Choose what actions to take, add new subscribers, update existing subscriber contact information, or unsubscribe existing contacts Logs error messages when encountered while communicating with Mailchimp, or form submission issues occur that prevent sending a subscription to Mailchimp Provides in-depth logging for each event during the subscription process while ProcessWire is in debug mode to help developers troubleshoot and/or understand what is happening behind the scenes. Mailchimp for FormBuilder aims to take advantage of the core features Mailchimp offers: Audiences (lists) - Collect subscriptions for any Audience in Mailchimp Audience Tags - Organize and segment your Mailchimp contacts with one or more tags Form Fields - Collect subscriber information using text, number, radio buttons, check boxes, drop downs, dates, addresses, website, and phone fields Interest Groups - Create Interest Group fields in Mailchimp to further organize your Contacts and create targeted campaigns Screenshots: Install the module and Mailchimp for FormBuilder, add your Mailchimp API key. Edit your form, choose your audience, save, and return to configure. In addition to configuration options, Mailchimp for FormBuilder includes robust high-level instructions under "How to configure..." that help less-technical end users manage their forms. Choose your audience, subscription action, and status. Choose whether to only accept new subscribers, add and update existing contacts, or unsubscribe. Pre-define whether a subscriber will receive HTML or plain text email, or choose a field to let people filling out your forms decide. Optionally mark subscribers as VIP, choose whether to provide Mailchimp the IP address at the time of signup. Choose from existing Audience Tags, or create new tags right in ProcessWire. Audience Tags created while editing one form makes available as an option to all forms. Choose which Mailchimp fields should receive form data, as many or as few as desired. If a field is set as required in Mailchimp, it will be required here too. Mailchimp only requires an email address for all subscribers by default. The Mailchimp fields you select appear below to choose the form field that should be associated with it. Notes appear below each field where helpful information is provided by Mailchimp to help you configure your form fields. If a field has multiple options in Mailchimp, such as dropdown/radio/checkbox fields, the values Mailchimp expects are noted so that you can ensure your fields submit the correct information. Date fields show the format that Mailchimp uses for that field, but Mailchimp for FormBuilder will automatically format your date fields to match what Mailchimp requires so no extra work required. Fields that are configured in Mailchimp as an Interest List show the value(s) your form field can submit. Collect information for complex fields such as addresses. Just choose the form field for each Mailchimp field. Required fields helps you make sure that your addresses will be accepted by Mailchimp. There is also an ability to choose a checkbox in your form that determines if a submission should be sent to Mailchimp. If a checkbox field is configured then a value is required to send your form data to Mailchimp, if it's left unchecked then the Mailchimp process is skipped entirely. This is a local field that is independently managed separately from Mailchimp. Known limitations: File upload fields can't be processed. While Mailchimp provides a file URL field that can be used in forms, the files uploaded via FormBuilder are not yet ready when this module parses submission data. Mailchimp has added GDPR settings but working with them via the API is a little more complex than expected. I'm not sure whether the GDPR features in Mailchimp are actually required for the EU, but I would like to eventually implement this to be feature-complete and more helpful for our European comrades. Considerations to keep in mind: This module makes every attempt to maintain parity between Mailchimp and ProcessWire configurations but there are reasonable limitations to be expected. Because data is being sent to a Mailchimp account from an external source (ProcessWire), it is not possible to automatically know if changes have been made in Mailchimp. When forms are submitted, the module processes and submits that data using the last known Mailchimp configuration when the form was edited in ProcessWire. When editing your forms, the latest settings are retrieved from Mailchimp so you're always working with the correct information. Audience Tags are a good example. Tags may be created, edited, and deleted in Mailchimp but it will still accept any tags submitted due to how the API works. So if you have a form that tags subscriptions "Special Offers" but that tag is deleted or modified in Mailchimp, "Special Offers" will be re-created the next time a form is submitted. If a Mailchimp field is changed to required but the form hasn't been updated then subscriptions may be rejected. So in short, while the module is designed to be powerful yet easy to use, ensure that forms are kept up to date with Mailchimp. This is unlikely to be an issue if clients manage forms, but good to keep in mind. Mailchimp for FormBuilder fails silently and takes the route of logging errors rather than preventing FormBuilder from continuing to process the submission. This is to prevent data loss and interruptions for the end user as well as allow other form actions to still take place. It is recommended that forms always store submissions in the database as long as this module is in use. This is an alpha release! I've built it for a project in-progress so it works as far as I've tested it. Please test thoroughly and share any bugs or issues here or by filing an issue on the Github repo. When it's had wider testing and thumbs up from users I'll submit it to the modules directory. Thanks! Download Mailchimp for FormBuilder from the Github repository here.
  2. @Klenkes That's a ProcessWire error for a field that is missing a value, not a Fluency error. Can you confirm that there are no empty required fields on the Fluency module config page? Fluency will not show any translation buttons unless the module has been properly configured.
  3. @Klenkes Well that's even worse... Can you confirm if the dev branch version resolves the issue?
  4. While this isn't a suggestion for the core, since it came up I want to second this. There are many great modules that would benefit from this- for both those that are currently available, and just as importantly, modules that have yet to be built. I can say for myself that there are some things that I'd love to create but it's difficult to justify considering the effort to build, maintain, and improve it without the potential to recoup some of the real costs involved. Listing the benefits of an official shop would make this comment far too long. I'm a big fan of the official Pro modules and I don't build sites without them, same has become true for those offered by @bernhard. This would also really help newcomers adopt ProcessWire if the modules directory could become a platform to browse both paid and free modules. Decoupling the Pro module purchase experience from the forums would be really helpful and I would argue foster wider adoption. I see this as a long-term sustainability initiative for the ecosystem as a whole. Maybe this could tie into ProcessWire core (hear me out) Maybe this could be supported in ProcessWire itself where the module is downloaded in ProcessWire (as free modules are currently), and then keys that are purchased in the store can be entered where modules are managed in the admin itself. Reducing the effort and friction of adopting high quality modules for more projects and providing true integrated support. I would love to install Pro modules as easily as those in the directory. I would expect, and hope, to see a per-sale fee for commercial modules sold in an official shop to both make this endeavor sustainable and to see something go back to the ProcessWire project. By that I mean I would like to build modules that buy @ryan a beer, preferably multiple because that means things are going well. There have been some great suggestions here, but I do want to take a moment for some thanks to the constant improvements that I've seen. Even the smaller quality of life changes in new versions are entirely appreciated as we enjoy a better developer experience. Cheers! 🍻
  5. @Klenkes @markus-th Addressing issues that pop up when upgrading the module have been difficult since changing how data is stored. Can you confirm that after updating that you checked the module configuration and confirmed that all of the settings are correct? Can you download and test the version I just pushed to the dev branch? This one works for me and has changes that may address the issue.
  6. Would be great to see .env support. I think this base change would level up ProcessWire and make it an increasingly viable application framework that fits into modern workflows. I think this would be a great idea. It would greatly help module development where modifying or appending behavior to existing elements is needed. I could see that having been useful when developing my Fluency module. I'll throw a thought in about multi-site capability after considering multi-tenancy for a Laravel project using a module. May be a nice feature but if there's a tradeoff for future features and development speed due to maintenance overhead, both for ProcessWire core and modules, it may not pencil out. I know it's not apples to apples, but creating two sites and a private API between them has worked for me in the past.
  7. @bernhard Wish I had more information but the only step was installing the module. I did some digging but am still coming up short. I removed the custom page classes and still got the error, so we can rule that out. Here's more detailed output after attempting to install again: The site is set up with language support, so that missing method error should not exist. I'm struggling to locate where exactly this issue is coming from because $page->localPath() is working everywhere else. Maybe this is an issue with language support installed before RockSettings is installed? I noticed this on lines 398-400 in SettingsPage.php <?php // make redirects be single-language $tpl = $rm->getRepeaterTemplate(self::field_redirects); $rm->setTemplateData($tpl, ['noLang' => true]); Shot in the dark, having trouble narrowing down where to look to find more information.
  8. @bernhard Well, I hate to be that guy today (haha) but one more issue and I think it's related to custom page classes(?) Seems odd because DefaultPage extends Page. ProcessWire version 3.0.229 and RockSettings version 2.2.1
  9. @bernhard It's the strangest thing... I am trying to set it to 'Hidden'. Which works when editing in template context. But after a modules refresh, it comes back with the original visibility. That's the only steps I can come up with 😆 All of the fields under that 'Media' fieldset keep popping back up after a modules refresh.
  10. @bernhard I am hiding some fields using the template context settings, but only the fields I mentioned aren't keeping those settings. All of the other fields keep the visibility settings.
  11. Hey @bernhard Everything is working great, just having an issue with fields having their Visibility re-set to 'Open' after refreshing modules. The only affected fields are Logo, Favicon, og:image, Images, and Files. So only the fields under the 'Media' fieldset. All of the other fields keep the visibility as configured in the template settings. Using version 2.0.1 Thanks!
  12. @bernhard Your modules have so many features I'm still getting to know them all haha. Awesome, will keep that in mind as well. I think I'm going to need these features a lot more on the project I'm getting started on.
  13. I meant to mention that I didn't know about that method before your comment. Now that I know about it though I'll probably end up using it. So thank you for the tip! There was one instance where it may have come in handy. I created a PageClass method that analyzes the current page request to determine if it was an AJAX request for a specific purpose, but decided to just call $page->whateverMethdName() at the top of the template before anything rendered. The loaded() method is the way I'll go with that next time. I learned something new today 😎 Bookmarking this. Keeping hooks closer to the location/context that makes sense is a good idea.
  14. Well, funny story, I have no reasoning. I just misremembered between reading that and hacking out this post. Jumping between projects in and outside of ProcessWire didn't help haha. I've used DefaultPage in the past, it was just an oversight this time around. Ryan's is of course the correct way. I generally always have a custom page class for each of my templates so I've never had an issue where DefaultPage was loaded on it's own. Appreciate you mentioning it, I've updated the post to use DefaultPage. My first reaction is that unexpected behaviors may indicate that there may be too much abstraction. My DefaultPage classes have traditionally been lightweight and stick to only containing what can be reliably counted on as valuable across all pages. One of the criticisms of OOP is that classes and inheritance can make for brittle code so it's something that I try to be mindful of. It may also be good to try an keep anything that executes automatically or hooks into the Page lifecycle to a minimum (maybe none at all) in DefaultPage since it spans the entire application. I use names like these myself for the same reason. Really helps keep a sane number of fields and reduce redundancy. I haven't taken this approach to reassigning values to properties like this but it's an interesting idea. I reviewed the code for the Page class and there is a good amount of logic in Page::set() that can be useful when working with values but may not be needed within the context of a page class. At first blush though I would just assign the value of the field to an instance variable directly rather than call Page::set() because at time of authorship- you know what field you'll be working with, it's data type, and intended usage within the context of that template/page. <?php public $introTitle; public $introText; // set custom properties in loaded public function loaded() { // load custom properties only for allowed statuses if (!empty(array_intersect(array_keys($this->status(true)), self::excludeStatuses))) return; $this->introTitle = $this->text; $this->introText = nl2br($this->textarea); } I may be missing the added value of using Page::set() in your use case though. My other thought though is that if these fields have consistent and wide enough usage through all templates, enough to reassign them to alternate property names, they might be a candidate for dedicated fields rather than repurposing generalized fields. Both introTitle and introText seem like they have wide enough usage, so I would create those fields and skip any extra logic needed here in DefaultPage. I actually haven't set a lot of instance variables in page classes. I've focused on methods that can abstract logic out of templates, or perform computationally expensive operations only on demand. Here's a DefaultPage from a production site: <?php namespace ProcessWire; use Ramsey\Uuid\Uuid as Uuid; use \DateTime; class DefaultPage extends Page { /** * Paths for rendering methods */ const COMPONENTS_PATH = '/../templates/components/'; const FORMS_PATH = '/../templates/components/forms/'; const PARTIALS_PATH = '/../templates/partials/'; const SVG_PATH = '/../templates/img/svg/'; /** * Stub method that may be overridded by inheriting classes where needed */ public function renderStructuredData(): ?string { return null; } /** * Outputs full path and filename for CSS files */ public function css(string $file): string { return wire('config')->urls->templates . "dist/styles/{$file}"; } /** * Outputs full path and filename for JavaScript files */ public function js(string $file): string { return wire('config')->urls->templates . "dist/scripts/{$file}"; } /** * Returns contents of an SVG file with unique IDs for title/description attributes for accessibility */ final public function renderSvg(string $file, array $variables = []): string { $variables = array_merge($variables, [ '{ARIA_TITLE_ID}' => Uuid::uuid4()->toString(), '{ARIA_DESC_ID}' => Uuid::uuid4()->toString(), '{CLASSES}' => $variables['classes'] ?? null, ]); return strtr(file_get_contents(__DIR__ . self::SVG_PATH . $file), $variables); } /** * Renders a file in /templates/partials/ */ final public function renderPartial(string $file, array $variables = []): string { return wire('files')->render($file, $variables, [ 'defaultPath' => __DIR__ . self::PARTIALS_PATH ]); } /** * Renders file located in templates/components/ */ final public function renderComponent(string $file, array $variables = []): string { return wire('files')->render($file, $variables, [ 'defaultPath' => __DIR__ . self::COMPONENTS_PATH ]); } /** * Renders a form */ final public function renderForm(string $file, array $variables = []): string { return wire('files')->render($file, $variables, [ 'defaultPath' => __DIR__ . self::FORMS_PATH ]); } /** * Gets the time period of the day based on the hour */ final public function getPeriodOfDay(): string { $hour = (new DateTime())->format('H'); switch (true) { case $hour < 12: return 'morning'; case $hour >= 12 && $hour <= 17: return 'afternoon'; default: return 'evening'; } } /** * Returns all top level pages for the main navigation */ final public function topLevelPages(): PageArray { $pages = wire('pages'); $selector = 'parent=1'; $excludedIds = $pages->get('template=admin_website_settings') ->nav_main_excluded_pages ->explode('id'); // Check for excluded pages from nav defined in the "Website Settings" page count($excludedIds) && $selector .= ',id!=' . implode('|', $excludedIds); $homePage = $pages->get(1); $topLevelPages = $pages->find($selector); $topLevelPages->prepend($homePage); return $topLevelPages; } } All of the other magic goes into traits or inheriting page classes. My soft guidelines when it comes to code organization is: Save abstraction until something is repeated 3 times, consistency should be maintained, or complexity warrant it for the sake of the surrounding code. Abstract to a trait or enum that can be selectively used where needed. Consider abstracting to a parent class last, there has to be a case for why it should be shared between all classes because it's more than ubiquity, it comes down to rock solid shared behavior and purpose
  15. This is incredibly useful and could not come at a better time!
  16. I'd be happy to provide something like that. I'm really short on free time right now but I may be able to adapt a real life example once I get enough done on one of my projects. That way I can avoid double work and have a good example. Will keep it in mind and bring that info back here as a demo repository when I can do it right. I've implemented the exact same method on sites before and that's a great example. I like to create a jsonld() method which returns null in my BasePage class that exists specifically to be overwritten by inheriting pages. That way I can call $page->jsonld() in a partial like site_header.php without any logic, it renders something or it doesn't.
  17. I second this wholeheartedly. It's truly too much to cover in a comment, but this is what I mean when I said "thinking with objects". It requires starting to work with encapsulation and breaking down/dividing your code into behavioral chunks. I'm pretty sure at some point I read the book that @da² recommended. The Head First series really does explain things in ways that I have seldom seen elsewhere. Also +1 for real life books, I find that they let me "unplug" from the problem at hand and focus on the content presented. In my closing list of items I mentioned "single responsibility", "composition over inheritance", "does not expose how it calculates", and an example of how properties and methods can each have their purposes and roles. You're going to immediately be introduced to these fundamental concepts as soon as you start down the OOP path, I peppered them in to contextualize them in ProcessWire. I've referenced this site before and it's a good next step. You'll write OOP code and sometimes think "there's got to be a better way to do this", and these are the abstract concepts that you'll be able to take into consideration. Just be mindful of "premature optimization" and get comfortable with OOP to the point where before you even start coding, your mental model will naturally begin with OOP. Approach LLMs with caution while learning. They require that you ask a question the right way and may not tell you why what you asked maybe isn't the right question to ask in the way a human can. You also have to be familiar enough with the concepts to recognize when they give you the wrong answer. LLMs require large scale data ingestion and that means their models are indiscriminately built with code examples whether they're good or bad. You'll get there but they're wrong, a lot, so maybe consider this one a little further down the road. Just my two cents.
  18. Very happy to hear you enjoyed it!. Writing a course on anything hasn't ever crossed my mind haha. I just put this together to share a great ProcessWire feature that has really boosted my workflow and thought maybe sharing some examples might be useful to others. I hadn't thought about this post as a walkthrough on OOP specifically, but I did want to be descriptive in a way that would be useful to as many people as possible- whether you are familiar with OOP or not, and without any assumptions. All said, I'm happy to try and answer questions if it would help anyone ?
  19. If anyone's interested, I posted a deeper dive into page classes with more real world examples. I like page classes so much I had to write a love letter.
  20. Page classes are an outstanding feature of ProcessWire and probably ranks among my favorites overall (and that's really saying something with ProcessWire). I don't remember how I lived without them, well I do, but I don't like to think about it. I wrote a response to a question asking how ProcessWire can help transition from procedural code to OOP and in the process (pardon the pun) of answering, I realized how much I've come to use, if not outright rely on, page classes in every project. I wanted to compile a few more thoughts and examples here because there may be devs who are finding this feature for the first time, and besides, why not go down the rabbit hole? Page classes have been around since ProcessWire 3.0.152 and if you're not familiar this will all make a lot more sense if you do a quick read of the feature announcement by Ryan here because this post assumes familiarity. We're also going to pick up the pace between the examples below compared to my comment in the link above. Here are a few goals that I have that page classes put solutions for within reach. You may have some of your own and I'd love to hear about those as well. Keep templates clean by restricting logic to flow operations- if statements and loops. Work with data in context, accessing data and fields closer to their source. Create "universal" methods available in every template, but also have scoped methods per-template Increase the scalability of a project, growth with limited increases in complexity. Stay DRY Embrace and extend the power of OOP in ProcessWire's DNA This seems like quite a list for one feature to handle, but rest assured, this is an example of how much power comes with using page classes. I'll continue and build on the blog example from my linked response above, and you can "follow along" with the Blog module since the templates mentioned are present out of the box. Some of the examples below are taken from production code, but please excuse any errors that I may have introduced by accident and I'm happy to update this post with corrections. Some of these things can be done other ways but they're just to illustrate, replace with your ideas and think of times that this would be useful. First up, what are some features and behaviors that everyone needs on every project? What are some universal methods that would be great to have everywhere? Let's start out by creating a "base" page class called DefaultPage. Going forward, page classes will extend this class instead of Page and benefit from having access to universal methods and properties. EDIT: I initially wrote this using BasePage rather than DefaultPage for this class name as described by the custom page class writeup by Ryan in the article above. I've changed this to now use the correct DefaultPage class name. <?php namespace ProcessWire; // /site/classes/DefaultPage.php class DefaultPage extends Page { /** * Returns all top level pages for the main navigation. */ public function navigationPages(): PageArray { $pages = wire('pages'); $selector = 'parent=1'; $excludedIds = $pages->get('template=website_settings') ->nav_main_excluded_pages ->explode('id'); // Check for excluded pages from nav defined in the "Website Settings" page count($excludedIds) && $selector .= ',id!=' . implode('|', $excludedIds); $homePage = $pages->get('id=1'); $topLevelPages = $pages->find($selector); $topLevelPages->prepend($homePage); return $topLevelPages; } } All your base page are belong to us. Right off the bat we've managed to pull complex logic usually in templates and kept our markup clean. This isn't for DRY, it's to store logic out of templates. This simple example only illustrates working with a top level page nav. We can start to appreciate the simplicity when considering how much more the navigation may call for in the future. A navigationChildren method that also accounts for excluded pages is a prime example. In our markup: <!-- /site/templates/components/site_nav.php --> <nav> <ul> <?php foreach ($page->navigationPages() as $navPage): ?> <li> <a href="<?= $navPage->url; ?>"><?= $navPage->title; ?></a> </li> <?php endforeach ?> </ul> </nav> Next up, our settings page has newsletter signup fields where users can copy/paste form embed code from Mailchimp. Our website has a "settings" page where global values and fields can be edited and maintained There are multiple textarea fields on the settings page that can each contain a different mailchimp embed code There is an embed select field that can be added to templates. The values are textarea field names so an embed can be chosen by the user. An embed select field has been added to the settings page which allows for choosing a default embed code if one isn't selected when editing a page Embedded forms should be available anywhere on any template <?php namespace ProcessWire; // /site/classes/DefaultPage.php class DefaultPage extends Page { // ...Other DefaultPage methods /** * Renders either a selected form embed, fallback to default selected form */ public function renderEmailSignup(): ?string { $settingsPage = wire('pages')->get('website_settings'); $embedField = $this->signup_embed_select ?: $settingsPage->default_email_embed; return $settingsPage->$embedField; } } Excellent. We've got some solid logic that otherwise wouldn't have a great home to live in without page classes. We can also modify this one method if there are additional options or complexity added to the admin pages in the future. Wherever we use renderEmailSignup, the return value will be either the selected or default form embed code. Let's create a folder called "components" in our templates directory that will hold standalone reusable markup we can use wherever needed, here's our newsletter signup component: <!-- /site/templates/components/newsletter_signup.php --> <div class="newsletter-signup"> <?= $page->renderEmailSignup(); ?> </div> Great! We've kept logic out of our code. We can render the field, and we can account for an empty value with a fallback. Unfortunately this is pretty limited in that it only handles a specific field, and it's implementation isn't as flexible as a component should be. We can fix that, and here are some new requirements to boot: Some templates may have multiple embed select fields which may or may not have a value Each embed select field is now paired up with a text field to add some content that appears with each form embed, think "Sign up for our newsletter today!" We want to render our mailchimp form embeds using the same component file, but handle different fields Sounds like a tall order, but it's a challenge easily overcome with page classes. We're going to introduce a new method called renderComponent that will be global, reusable, and very flexible. We're also going to make use of another very great ProcessWire feature to do it. Back to our DefaultPage class: <?php namespace ProcessWire; // /site/classes/DefaultPage.php class DefaultPage extends Page { // ...Other DefaultPage methods /** * Renders a file located in /site/templates/components/ with optional variables */ final public function renderComponent(string $file, ...$variables): string { $componentsPath = wire('config')->paths->templates . 'components/'; return wire('files')->render($file, $variables, ['defaultPath' => $componentsPath]); } /** * Renders either a selected form embed, fallback to default selected form */ public function renderEmailSignup(?string $embedSelectField = null): ?string { $settingsPage = wire('pages')->get('website_settings'); $embedField = $this->$embedSelectField ?: $settingsPage->default_email_embed; return $settingsPage->$embedField; } } We've done a couple of things here. We've updated renderEmailSignup to accept a field name, so now we're flexible on exactly which field select we'd like to check for a value on before falling back to default. We've also created a renderComponent method that is going to be super useful throughout the rest of our ProcessWire application. Our renderComponent receives a file name located in the components directory and any number of named parameters. You could change the $variables parameter to an array if you'd like, but I'm a big fan of the great features we have now in PHP 8+. Here's our refactored component file: <!-- /site/templates/components/newsletter_signup.php --> <?php if ($embedField): ?> <div class="newsletter-signup"> <?php if ($text): ?> <h2><?= $text; ?></h2> <?php endif ?> <?= $page->renderEmailSignup($embedField); ?> </div> <?php endif ?> And let's hop over to our (abbreviated) home page template: <body> <!-- ...Sections full of great content --> <section class="signup-call-to-action"> <?= $page->renderComponent('newsletter_signup.php', embedField: $page->embed_select, text: $page->text_1); ?> </section> <!-- ...Your awesome template design --> <section class="page-end-call-to-action"> <?= $page->renderComponent('newsletter_signup.php', embedField: $page->embed_select_2, text: $page->text_2); ?> </section> <!-- Your footer here --> </body> I don't know about you, but this is looking really good to me. The number of things we've accomplished while having written so little code is remarkable: Because we used wire('files')->render(), the entire ProcessWire API is available within the component, so now our renderEmailSignup method is too. The variadic function parameters (or array if preferred) let us pass an arbitrary number of variables to any component file, unrestricted future flexibility Variables are scoped to each component! There's no reference to template fields in our component that could break if changes are made No more PHP includes, we don't have to juggle paths or constantly repeat them in our code, nor rely on declaring variables before including a file. ProcessWire will throw an exception if we try to render a file that does not exist which makes locating issues very easy We'll also see an exception if we try to reference a variable in our component that wasn't passed which can also help troubleshooting. Notice that the renderComponent is final. We want that behavior to remain consistent everywhere we use it and not overwritten either intentionally or by accident on our inheriting page classes. We want to eliminate any confusion between templates by knowing it will always do the same thing the same way. We can explore other uses too, perhaps a renderPartial for files in /site/templates/partials where we store files like site_header.php. As mentioned above however, if a variable is expected in the rendered file but not included in our render method, we'll see an exception. Let's use site_header.php as an example because we're sure to run into situations where variables may or may not exist: <?php namespace ProcessWire; // /site/templates/partials/site_header.php ?> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title><?= $page->title; ?></title> <meta name="description" content="<?= $metaDescription ?? null; ?>"> <?php if ($includeAnalytics ?? true): ?> <script> // All that Google Analytics jazz </script> <?php endif ?> </head> <body class="<?= $bodyClasses ?? null; ?>"> <header> <?= $page->renderComponent('site_nav.php', includeEmailButton: true); ?> </header> Problem: solved. By using nullsafe ?? operators, we can call $page->renderPartial('site_header', description: 'The ultimate Spice Girls fan page.'); and never get errors for variables that may not be included when calling renderComponent, such as includeAnalytics, which now also has a default value of 'true'. Nice. We haven't even gotten to our actual page classes yet... Our templates are about to receive superpowers. Let's take our blog to the next level. In my comment on the other thread, I created a specific example of adding a readTime method to our blog posts, let's go one level higher to our blog.php template. We'll populate some methods up front and then talk about what we've done: <?php namespace ProcessWire; // /site/classes/BlogPage.php class BlogPage extends DefaultPage { /** * Get latest blog posts, optionally with/without pinned post, optionally in blog category */ public function latestPosts( int $limit = 3, bool $includePinnedPost = true, ?int $categoryId = null ): PageArray { $selector = 'template=blog-post'; if ($categoryId) { $selector .= ",blog_category={$categoryId}"; } $posts = wire('pages')->get($selector); if ($includePinnedPost) { $pinnedPost = $this->getPinnedPost(); if ($pinnedPost) { $posts->remove($pinnedPost); $posts->prepend($pinnedPost); } } return $posts->slice(0, $limit); } /** * Gets an optional pinned post if set/chosen * pin_blog_post A checkbox to indicate whether a post should be pinned * pinned_post A InputfieldPage field to choose a blog post */ public function getPinnedPost(): ?Page { if (!$this->pin_blog_post) { return null; } return wire('pages')->get($this->pinned_post); } } On our main blog page a user has the ability to choose whether a blog post is "pinned". A pinned post will always remain the first post anywhere a list of posts is needed, something like a big company announcement that the client wants to keep visible. These two methods alone have given us awesome abilities. For our main blog page, when someone visits our blog page, the most recent or pinned post is presented at the top, followed by the next two most recent posts, followed by two rows of 3 posts, for a total of 9. Let's assume that we've already created the BlogPostPage.php with the readTime method from my previous example. Here's our blog.php template <?php namespace ProcessWire; echo $page->renderPartial( 'site_header.php', bodyClasses: 'blog-page', metaDescription: $page->blog_description ); $posts = $page->latestPosts(9); $firstPost = $blogPosts->first(); ?> <section class="main-post"> <article> <img src="<?= $firstPost->blog_image->url; ?>" alt="<?= $firstPost->blog_image->description; ?>"> <h1><?= $firstPost->title; ?></h1> <?= $firstPost->summary; ?> <span><?= $firstPost->readTime(); ?></span> <a href="<?= $firstPost->url; ?>">Read More</a> </article> </section> <section class="recent-Posts"> <?= $page->renderComponent('blog_preview_card.php', blogPost: $posts->get(1)); ?> <?= $page->renderComponent('blog_preview_card.php', blogPost: $posts->get(2)); ?> </section> <section class="past-posts"> <?php foreach ($posts->slice(3, 6) as $post): ?> <?= $page->renderComponent('blog-preview_card.php', blogPost: $post); ?> <?php endforeach ?> </section> <?= $page->renderPartial('site_footer.php'); ?> So first off, we've really started to use our renderComponents method and component files. We also implemented a renderPartial as speculated upon above. Each does a similar thing, but having separate methods makes everything clear, handles paths, but still has a similar interface when calling them. A big thing to notice here is that at no point have we added any markup to our page classes, and no business logic to our templates. If we need to find anything, we know where to look just by glancing at the template. Ultimate maintainability. Here's our blog_preview_card.php component: <?php namespace ProcessWire; ?> <!-- /site/templates/components/blog_preview_card.php --> <article class="blog-preview-card card"> <img src="<?= $blogPost->blog_image->url; ?>" alt="<?= $blogPost->blog_image->description; ?>"> <h2><?= $blogPost->title; ?></h2> <div class="blog-summary"> <?= $blogPost->blog_summary; ?> </div> <span class="blog-read-time"><?= $blogPost->readTime(); ?></span> <a href="<?= $blogPost->url; ?>">Read More</a> </article> I am liking how well this is working out! Page classes have done a ton of heavy lifting here: We're using our renderComponent method to it's maximum potential and it's payed off in spades Our template couldn't be cleaner or more easily maintainable BlogPostPage.php has taken care of all of our needs as far as delivering the PageArray of posts and all our template does is output the data as needed Our "card" component will render the same thing everywhere and we can update how that looks globally with changes to one file If you don't think this can get more awesome, or think this post is already too long, I have bad news for you and you should stop reading now. Still here? Let's create a BlogPostPage class and add a method: <?php namespace ProcessWire; // /site/classes/BlogPostPage.php class BlogPostPage extends DefaultPage { // ... Other page methods, like readTime() public function relatedPosts(int $limit = 3): PageArray { return wire('pages')->get('template=blog')->latestPosts( limit: $limit, includePinnedPost: false, categoryId: $this->blog_category?->id ); } } The BlogPage::latestPosts method is really flexing it's muscle here. We've used it in two different places for different purposes but requesting similar data, blog posts. If you noticed, we're also specifying a category for this blog since we have a page select field that references a blog category. That was a parameter that we included back in our BlogPage::latestPosts() method. So, blog posts have a "You might also be interested in..." section with posts in the same category as the that one that a visitor has just read. With page classes this couldn't be easier to add, so let's use the relatedPosts method we just created in BlogPostPage.php in our blog-post.php template: <?php namespace ProcessWire; // /site/templates/blog-post.php echo $page->renderPartial( 'site_header.php', bodyClasses: 'blog-post', metaDescription: $sanitizer->truncate($page->blog_summary, 160) ); ?> <!-- The page hero, blog content, stuff, etc. --> <section class="related-posts"> <?php foreach ($page->relatedPosts() as $post): ?> <?= $page->renderComponent('blog_preview_card.php', blogPost: $post); ?> <?php endforeach ?> </section> <?= $page->renderPartial('site_footer.php'); ?> We've just added a related posts feature in *checks stopwatch* seconds. This project is going so fast that you can already hear the crack of the cold beer after a job well done. One more example, and I'll let you decide if this is too much, or feels just right. We want a blog feed on our home page. So before we go to the home.php template, let's do what we do (surprise, it's a page class): <?php namespace ProcessWire; // /site/classes/HomePage.php class HomePage extends DefaultPage { // Other HomePage methods... public function blogFeed(int $limit = 3): PageArray { return wire('pages')->get('template=blog')->latestPosts(limit: $limit); } } At first glance, this might seem like a bit much. Why create a method in our page class that is so short and simple? We could just call $pages->get('template=blog')->latestPosts(3) inside our template and get away with it just fine. Here's why I think it's worth creating a dedicated page class method: This creates yet another example of predictability between templates It promotes thie philosophy I like of page classes talking to page classes It's likely that we'll have other more complex methods in the HomePage class, keeping them together feels right and helps with uniformity- we always know where to look when seeing custom methods If we need to make a change to the BlogPage::latestPosts() method that affect how other page classes call that method, we don't have to root around in our templates to make any changes. It's pretty nice to see that thus far we have only ever referenced the $page object in our templates! There aren't any calls to $pages because our template is really about doing one thing- rendering the current page without caring about other pages. That's not to say that we can't, or shouldn't, use other ProcessWire objects in our templates, but it's still impressive how much we've been able to scope the data that we're working with in each template. I truly love the page classes feature in ProcessWire and even though this is a deeper dive than my other post, this really is still just scratching the surface because we can imagine having more complex behavior and other benefits. Here are some from my experience: Handling AJAX calls to the same page. Choosing how and what type of content is returned when the page is loaded is really nice. This can be done with hooks, but I really like page classes handling things like this in-context. Working with custom sessions between one or more pages. Creating a trait that shares session behavior between page classes is a great way to extend functionality while keeping it scoped. Adding external API calls and whatever complexity that may entail Provides the ability to add significant complexity to our templates in the admin yet little to no additional complexity in template files. The basic "pinned post" and form embed features are just the start As you can imagine, this makes replicating code you use often between projects trivial Page classes provide a home for logic that otherwise would be stuck in a template, or relegated to a messy functions.php file. No more functions.php ever. We met every single goal I listed here at the beginning Every single template thus far only uses if statements and loops DefaultPage could have a "bootApplication" method if needed that does things for every page and is called in ready.php. This is also a great way to create a "bootable" method in all of your page classes where bootApplication could call a bootPage method in your page classes if it exists. Thanks for coming to my TED talk. Hope you find this useful! If you have any questions, corrections, use cases, or things to add, the comments below are open and I'd love to hear them!
  21. That sounds pretty great. This is probably the most annoying part, all of that is rendered on the client side in JS. I wish I rendered it client side by hooking into the render methods for the inputfields, but I didn't think about that at the time. So the challenge would be waiting until the translation buttons are rendered first, which is pretty fast because it doesn't involve any requests to initialize. The only delay is for the js file itself to load and the elements to be appended in the DOM. I really want to make some internal improvements to Fluency, especially as nifty things like this come about so the module can be more easily worked with and extended. If I can be of any help or if there's any questions you have while slogging through my code, I'm here to help haha. PRs are open if you end up finding some things that can be improved anyway. Looking forward to seeing what you come up with!
  22. @Martin1 Great idea! Thinking out loud, I'm considering a couple of things by way of how to implement. At it's core, Fluency is intended to provide like-kind behavior that is similar enough between current and future translation services/engines so that the module overall doesn't stray into having over-specificity for functionality. Like having general module methods that reach too far into features or behavior of specific engines. So my first thought is an add-on module to supplement DeepL's abilities- but this is open source so contributors should definitely have input that can make it better. Some thoughts below on addressing the issues you listed- I think that Google Cloud translate may have a similar feature but it would come down to how well the behavior could be abstracted to provide a common enough interface at the engine level where a common Data Transfer Object, and thus a feature interface, can be shared. Final classes- the DataTransfer Objects are definitely a "hard" final class where each should inherit from the base abstract DTO. There is some extra flexibility built in with the ability to add a 'meta' property that can hold some engine-specific behavior, like "formality" which DeepL supports but others like Google don't. Other instances of removing final are probably on the table, I'm not sure I should have made the Fluency module class final. A glossary would definitely warrant it's own DTO in the same spirit as the EngineApiUsageData, EngineTranslationData, EngineLanguagesData, etc. represent data passed between domains for individual features. Otherwise it's probably just a matter of following the Principle of Least Privilege. Unhookable methods- I don't good excuse for this one. Basically left out because hookable methods don't allow for named arguments. This is 100% open to change and was pretty short sighted on my part given that this is a module after all... I should just push a release that does adds hooks regardless. Private methods- This pretty much just follows the Principle of Least Privilege again and, like the final classes notes above, many are open for changing that. All said, I'm kind of open here as far as integrating it into Fluency or opening up class extension, public-ifying methods, and adding hooks. All said, is there one approach that feels better to you?
  23. @gornycreative I've uploaded a new version of the module to the dev branch in the repository and it would be great if you can take it for a spin. The UI is fixed so that the field renders with the correct styling. Working with the values introduced some new opportunities and I took some notes from the feedback on the FormBuilderHtmx module on return values. The new rendering options are: Form ID or null when no form is selected (int) Form name or null when no form is selected FormBuilder form object or null when no form is selected Adding the ability to work with the FormBuilder object directly affords some extra utility, it's a direct passthrough in that FieldtypeFormSelect retrieves and returns the form that $forms would have had you done that manually. This was in lieu of using a Page select field since Pages and Forms are retrieved differently. Examples are included on form config pages. I kept the Form ID option as default to help prevent a breaking change, but I haven't directly tested this version against the last version so let me know if you run into any issues. Download from dev, I'll push a new version if everything works out well.
×
×
  • Create New...