Skip to content
View in the app

A better way to browse. Learn more.

ernestdefoe.online

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.
ernestdefoe.online

Extensions, themes & support for Flarum and Invision Community

Vibe coding for the community web. Report a bug, request a feature, or dig into the source — this is where the tools you use get built, in the open.

We do custom Bespoke Invision Community apps. If you have an idea for something you want then use the contact form to get in touch with us.
Knowledge base

Things that cost me a day, so they cost you none

Working notes from building Invision Community and Flarum applications. Mostly the failures that give no error at all — the ones where everything installs cleanly and quietly does the wrong thing.

92 articles

Invision Community 5

86 articles

Extensions and contracts

39

What each extension point is for, what it must declare, and what happens when it is wrong — which is usually nothing visible.

Languages and text

5

The string table, translation, and the places where text does not appear where you expected it to.

Theming, templates and forms

9

Theme hooks, CSS that survives both colour schemes, and building forms that do not throw on render.

Background work and scheduled tasks

5

The queue system, work that has to happen after the response, and jobs that finish without doing anything.

Data, settings and storage

11

The database layer, settings, tags, file storage, and backing up a live site.

AI features and expectations

5

What these features do, what they cost, and what buyers reasonably but wrongly assume they do.

Application structure and releases

11

The JSON files an application is made of, versioning and upgrade steps, and testing from the command line.

Realtime, chat and calls

1

WebSocket gateways, relays and the server-side pieces live features depend on — where "it works when I test it" and "it works for your members" are different claims.

Nothing matches that.

The core/ProfileSteps extension in Invision Community 5

When a member with an unfinished profile loads any front-end page, Invision Community shows a small panel above the content: a progress bar, "Next Step: Birthday", and a "Complete my profile" button. Pressing it opens a wizard at app=core&module=system&controller=settings&do=completion that walks the member through the outstanding items one form at a time. An administrator decides what those items are under AdminCP → Members → Profiles → Profile Completion, where each row is a "step" — a title, some explanatory text, an action, and a flag saying whether the step is merely suggested or required before the member may use the site. A core/ProfileSteps extension is what puts an entry in the list of actions an administrator can choose from, and then supplies the form the member fills in and the test that decides whether they have finished.

The extension is used at three separate moments and by different halves of its own contract: the static methods build the ACP form, the static wizard() builds the member-facing forms, and the instance methods answer "is this done?" for the progress bar. Almost every trap in this article comes from those three moments disagreeing with each other.

The contract

The abstract is IPS\Extensions\ProfileStepsAbstract, at system/Extensions/ProfileStepsAbstract.php. It declares twelve methods, four of them abstract. There are no properties and no constructor.

namespace IPS\Extensions;

abstract class ProfileStepsAbstract
{
    /* Line 35 */
    abstract public static function actions(): array;

    /* Line 42 */
    abstract public static function subActions(): array;

    /* Line 50 */
    public static function actionMultipleChoice( string $action ): ?bool  // default FALSE

    /* Line 62 */
    abstract public function completed( ProfileStep $step, ?Member $member = NULL ): bool;

    /* Line 70 */
    public function canComplete( Member $member ) : bool                  // default true

    /* Line 81 */
    public static function canBeRequired(): array                         // default array()

    /* Line 91 */
    public static function allowMultiple() : array                        // default array()

    /* Line 103 */
    public function url( string $action, ?Member $member = NULL ): ?Url   // default null

    /* Line 115 */
    public function postAcpSave( ProfileStep $step, array $values ) : void

    /* Line 128 */
    public static function formatFormValues( array $values, Member $member, Form $form ) : void

    /* Line 139 */
    abstract public static function wizard( ?Member $member = NULL ): array|string;

    /* Line 147 */
    public function onDelete( ProfileStep $step ) : void
}

Note which are static and which are not. actions(), subActions(), actionMultipleChoice(), canBeRequired(), allowMultiple(), formatFormValues() and wizard() are static; completed(), canComplete(), url(), postAcpSave() and onDelete() are instance methods. Core calls the static ones through an object it has already built ($extension::actions()), which is legal PHP and works either way, but redeclaring a method with the opposite staticness is a compile-time fatal error.

Two further methods exist that are not in the abstract at all and are called only through method_exists(): resync() and the pair extraStep() / extraStepTitle(). They are described under "The two undeclared methods" below.

actions()

Returns array( 'action_key' => 'language_key' ). Each entry becomes one radio option on the ACP "Action to complete the step" field. Returning an empty array is legitimate and means "I have nothing to offer right now" — core's own ProfileFields extension returns array() when no custom profile fields exist (applications/core/extensions/core/ProfileSteps/ProfileFields.php:50-60).

The action key is the value stored in core_profile_steps.step_completion_act and is the key everything else routes on. It is a single flat namespace shared by every application on the site — see "Two applications claiming the same action key" below.

subActions()

Returns a two-level array: array( 'action_key' => array( 'sub_key' => 'language_key' ) ). The outer keys must be action keys you returned from actions(). The inner array becomes the options of a second ACP field, and the administrator's choice is stored as JSON in core_profile_steps.step_subcompletion_act and read back as a PHP array by ProfileStep::get_subcompletion_act() (system/Member/ProfileStep.php:188-197).

An action with no entry in subActions() simply gets no second field — ProfileStep::form() guards with isset( $subActions[ $key ] ) at ProfileStep.php:571.

actionMultipleChoice()

Decides which form helper renders the sub-action field, at ProfileStep.php:587-597:

  • TRUEIPS\Helpers\Form\CheckboxSet, the administrator may pick several sub-actions.
  • FALSE (the inherited default) → IPS\Helpers\Form\Select, exactly one.
  • NULLIPS\Helpers\Form\Translatable with an editor. There are no options at all; the administrator types free text, and that text is what lands in subcompletion_act. Only core's Editor extension does this.

completed()

The only question core asks about a member's state. It receives the ProfileStep row, so if your extension owns more than one action you must branch on $step->completion_act yourself, and read $step->subcompletion_act to know which sub-items were selected. Return TRUE when there is nothing left for this member to do.

Returning TRUE for members who cannot do the thing is the established convention, not an edge case. Core's Core, Photo and ProfileFields extensions all return TRUE outright when $member->group['g_edit_profile'] is false, with the comment "Member has no permission to edit profile" (Core.php:284-288, Photo.php:211-215, ProfileFields.php:124-128).

canComplete()

Added for steps that some members should never be shown at all. Returning FALSE makes Member::profileCompletion() skip the step entirely, so it is excluded from the percentage rather than counted as outstanding (system/Member/Member.php:5983-5986), and makes the registration flow ignore it (applications/core/modules/front/system/register.php:416). The difference from completed() matters: completed() returning TRUE counts the step as done and moves the percentage up; canComplete() returning FALSE removes it from the denominator.

canBeRequired()

Returns a flat list of action keys. Only those keys get a toggle for the "Required" yes/no field on the ACP form (ProfileStep.php:603-606). If your action is not listed the field is hidden when your action is selected, so an administrator cannot make your step mandatory.

allowMultiple()

Returns a flat list of action keys that may be used by more than one step. Without this, once every sub-action of an action has been used by some step, ProfileStep::form() removes the action from the radio list altogether (ProfileStep.php:575-585). Core's Editor returns array( 'custom' ) because an administrator may want several custom-message steps.

formatFormValues()

Static. Takes raw form values and writes them onto the member. Your own wizard closures normally call it themselves — every core implementation does — but core also calls it for you at the end of registration, at register.php:718-725, for every step that is not flagged "show on registration", passing the whole registration form's values. So it can be handed an array containing nothing of yours, and it must not assume its keys exist. Only three of core's four extensions declare it at all — Core, Photo and ProfileFields; Editor inherits the no-op — and each guards only its top-level key with array_key_exists() or isset(). Below that they assume: Photo.php:121 reads $values['member_photo_upload'] unguarded once pp_photo_type is present, and Core.php:245-247 reads $values['bday']['month'] unguarded once bday is present. Guard your own accesses at every level rather than copying that.

wizard()

Static. Returns an array of wizard steps: keys are language keys, values are callables that take the accumulated wizard data and return either a string of HTML to display or an array to advance to the next step. That is the plain IPS\Helpers\Wizard contract, documented at system/Helpers/Wizard/Wizard.php:34-57 and enforced in its __toString() at Wizard.php:193-232.

The array keys are not free-form. They must equal $step->key, which ProfileStep::get_key() defines as "profile_step_title_" . $this->id (ProfileStep.php:410-413). Anything else is discarded — see "My wizard step never appears" below.

The declared return type is array|string, but nothing in 5.0.19 usefully consumes a string. Three of the four call sites test is_array() first (ProfileStep.php:244, settings.php:2111, register.php:441) and silently throw a string away; the fourth, the inline registration loop at register.php:261, foreaches the return value directly with no guard, so a string there raises a PHP warning and contributes nothing. Always return an array.

postAcpSave() and onDelete()

postAcpSave() runs immediately after an administrator saves a step, at applications/core/modules/admin/membersettings/profilecompletion.php:155. The $values array has already been through ProfileStep::formatFormValues(), which strips the profile_ prefix from every key, so the administrator's sub-action choices arrive as $values['step_subcompletion_act']. Core uses this hook to keep other settings in step: ProfileFields forces the chosen profile fields to pf_show_on_reg = 1, and Editor saves the custom message as a language string.

onDelete() runs from ProfileStep::delete() at ProfileStep.php:170-173, before the row is removed, and is used to undo the same things.

url()

Declared in the abstract and implemented by ProfileFields and by Nexus's CustomerFields, both returning the member's edit-profile URL. In 5.0.19 nothing calls it. The only routes to a ProfileStepsAbstract instance are ProfileStep::get_extension(), ProfileStep::loadExtensionFromAction() and Application::allExtensions(), and a grep across the suite for every use of those finds calls to onDelete(), subActions(), completed(), canComplete(), resync(), extraStep(), wizard(), formatFormValues() and postAcpSave() — and no call to url(). Implement it if you like, but do not rely on it being used. This is an observation about 5.0.19 and may change.

Registering the extension

The file belongs at applications/<app>/extensions/core/ProfileSteps/<Class>.php, in namespace IPS\<app>\extensions\core\ProfileSteps, and must be listed in applications/<app>/data/extensions.json:

{
    "core": {
        "ProfileSteps": {
            "MySteps": "IPS\\myapp\\extensions\\core\\ProfileSteps\\MySteps"
        }
    }
}

Core's own registration, at applications/core/data/extensions.json:294-299, lists four: Core, Editor, Photo and ProfileFields. Nexus adds one, CustomerFields. No other application in the suite implements this extension point.

Application::allExtensions() keys the resolved list as <appDirectory>_<extensionKey> (system/Application/Application.php:419), and that string is what is written into core_profile_steps.step_extension when a step is saved. It is read back by splitting on the first underscore — which constrains your naming; see "The extension key must not contain an underscore" below.

The list is cached in the datastore under the key extensions, which is listed in Application::$caches at Application.php:117. Copying files in by hand and editing the JSON clears nothing; use ACP → Support → Clear Caches, or reinstall the application. A class name in the JSON that fails class_exists() is skipped with a bare continue and no log entry (Application.php:926-930).

The Developer Center's template for a new extension is at applications/core/data/defaults/extensions/ProfileSteps.txt. It generates the four abstract methods and nothing else, with actions() and subActions() returning empty arrays and completed() returning FALSE.

Language keys

Four families of key are involved, and only one of them is under your control through a return value.

  • The radio label is whatever key you returned as the value in actions(). IPS\Helpers\Form\Radio treats option values as language keys by default.
  • The sub-action field label is profile_step_subaction_<actionKey>, hard-coded at ProfileStep.php:591 and 596. Nexus defines profile_step_subaction_customer_fields for its customer_fields action (applications/nexus/dev/lang.php:37).
  • The automatic ACP step title is complete_profile_<actionKey>, hard-coded three times in ProfileStep::get_acpTitle() (ProfileStep.php:425, 442, 449). This is a separate key from the radio label and is easy to miss, because for most of core's actions the two happen to be the same string. They are not for ProfileFields, which returns complete_profile_app__core_ProfileFields as the radio label but still needs complete_profile_profile_fields for the title (both exist, at applications/core/dev/lang.php:7692 and 7678). Nexus likewise ships both complete_profile_app__nexus_CustomerFields and complete_profile_customer_fields.
  • Sub-action option labels are the values you returned from subActions().

Nothing warns you about a missing key. The Developer Center's language-scan table at applications/core/modules/admin/developer/details.php:565-577 covers ModeratorPermission, CommunityEnhancements, Dashboard, EditorLocations, EditorMedia, FileStorage, GroupForm, IpAddresses, LiveSearch, MemberACPProfileTabs, MFAArea, AchievementAction and AdminNotificationsProfileSteps is not in it. A missing key shows up as the raw key on the ACP form, and a missing complete_profile_<actionKey> shows up as the raw key in the step list.

The per-step title and description that members see, profile_step_title_<id> and profile_step_text_<id>, are custom language strings written by ProfileStep::postSaveForm() (ProfileStep.php:724-725). You do not define those.

Who calls it

Six places, in three groups.

Building the ACP form. ProfileStep::actions(), subActions(), actionMultipleChoice(), canBeRequired(), allowMultiple() and findExtensionFromAction() each loop Application::allExtensions( 'core', 'ProfileSteps' ) and flatten the results (ProfileStep.php:306, 328, 352, 375, 394, 743). These run when an administrator opens AdminCP → Members → Profiles → Profile Completion or its add/edit form. postAcpSave() runs on save (profilecompletion.php:155), and the same save resets the profile_completed bit on every member on the site (profilecompletion.php:157), so adding a step re-prompts everybody.

Showing the member the wizard. Two controllers do the same thing: applications/core/modules/front/system/settings.php:2109-2121 for the "Complete my profile" wizard, and applications/core/modules/front/system/register.php:439-451 for the mandatory steps after registration. Both merge every extension's wizard() output, then any extraStep() output, then pass the lot through ProfileStep::setOrder() and hand it to IPS\Helpers\Wizard. A third, narrower call is at register.php:256-278, which inlines the wizard for steps flagged "show on registration" into the registration form itself.

Measuring completion. Member::profileCompletion() (Member.php:5971-5993) loops every saved step, skips those where canComplete() is false, and records completed() for the rest, split into required and suggested. Member::profileCompletionPercentage() (Member.php:6001) turns that into a number, and when everything is done sets members_bitoptions['profile_completed'] permanently and fires the core / ProfileCompletion achievement action. Member::nextProfileStep() (Member.php:6059) returns the first incomplete suggested step, and is what the global template tests at applications/core/dev/html/front/global/globalTemplate.phtml:138 — together with the profile_completion_dismissed bit and the ipsLayout_minimal body class — to decide whether to draw the banner on every page.

Note the default $checkAccess of allExtensions() is TRUE, so on every one of these calls your extension is included only if the current viewer can access your application (Application.php:406-412). During registration the viewer may still be a guest.

The two undeclared methods

resync() takes a ProfileStep and is called from ProfileStep::resync() at ProfileStep.php:793-796, guarded by method_exists(). Its job is to repair steps after something they referenced disappears: core's ProfileFields version drops sub-actions whose profile field no longer exists and deletes the step outright if none are left. ProfileStep::resync() itself is only triggered by applications/core/sources/ProfileFields/Field.php:533 and applications/nexus/sources/Customer/CustomField.php:185. Nothing will call it for you; if your sub-actions can be deleted from elsewhere in your application, call ProfileStep::resync() at that point yourself.

extraStep() and extraStepTitle() are both static and are used for a follow-up form that only makes sense after the main step has been submitted. Core's only use is cropping a photo the member has just uploaded (Photo.php:251-273). They are picked up by method_exists() in the two wizard controllers and in ProfileStep::setOrder() (ProfileStep.php:841-848). Only extraStep() is probed — setOrder() then calls $extension::extraStepTitle() unguarded at ProfileStep.php:844-846, so declaring extraStep() without extraStepTitle() is an Error: Call to undefined method. Declare both or neither. extraStepTitle() returns a single language key which is used as the wizard array key, so it is exempt from the profile_step_title_<id> rule below.

My wizard step never appears

This is the failure that costs people an afternoon. Your wizard() returns a perfectly good array of closures, the administrator has created the step, the banner says the profile is incomplete, and the wizard skips straight to the end.

Both controllers pass the merged array through ProfileStep::setOrder() before handing it to the Wizard helper (settings.php:2121, register.php:451). setOrder() does not sort the array you gave it — it builds a new one, and copies across only the entries it recognises (ProfileStep.php:829-853):

foreach( $profileSteps as $id => $profileStep )
{
    if( isset( $steps['profile_step_title_' . $id ] ) )
    {
        $finalSteps['profile_step_title_' . $id ] = $steps['profile_step_title_' . $id ];
    }
    ...
}

Any key that is not profile_step_title_<id> for a step that actually exists in core_profile_steps, or the return of an extraStepTitle(), is dropped on the floor. No exception, no log entry. Key your array with $step->key, taken from a ProfileStep object you loaded from ProfileStep::loadAll() — which is exactly what all five core and Nexus implementations do.

The related mistake is returning steps for rows that are not yours. Every implementation loops ProfileStep::loadAll() and filters on $step->completion_act === 'my_action'. Skip that test and you will render your form for another extension's step.

The member is prompted forever

Also silent, also common, and it comes from completed() and wizard() disagreeing.

The banner is drawn whenever nextProfileStep() returns a step, which happens whenever some suggested step's completed() returned FALSE. The wizard, separately, only offers the forms your wizard() chose to return. If completed() says "not done" but wizard() returns nothing for that member — because of a permission check you applied in one place and not the other, or because the member chose an option your completed() does not accept — the wizard runs to its profile_done step, redirects, and the banner is still there. The member can repeat that loop indefinitely; their only escape is to dismiss the banner, which the template also checks (globalTemplate.phtml:138 tests members_bitoptions['profile_completion_dismissed'] alongside nextProfileStep()).

Core hit this itself with the "no photo, thanks" option and worked around it with a session flag, $_SESSION['profileCompletionData'][] = 'photo-none', set in Photo::formatFormValues() and read by Photo::completedPhoto() (Photo.php:133-144 and 292-307). Whatever your equivalent is, the rule is that the two methods must agree for every member, including members who legitimately cannot complete the step. When they cannot, return TRUE from completed(), or FALSE from canComplete().

Remember also that completion is latched. Once every step is done, profileCompletionPercentage() sets members_bitoptions['profile_completed'] and thereafter returns 100 without consulting your extension at all. While testing, clear that bit or save a step in the ACP, which clears it for everyone.

The extension key must not contain an underscore

ProfileStep::get_extension() splits the stored <appDirectory>_<extensionKey> string like this (ProfileStep.php:466-475):

list( $app, $extension ) = explode( '_', $this->_data['extension'] );
try
{
    $class = Application::getExtensionClass( $app, 'ProfileSteps', $extension );
    return new $class;
}
catch( OutOfRangeException )
{
    return null;
}

explode() is unlimited, so an extension named My_Steps in application myapp yields $app = 'myapp' and $extension = 'My', and an application directory of my_app is worse still. getExtensionClass() then throws OutOfRangeException, which is caught, and get_extension() returns NULL.

What the developer sees is not a helpful error. The very next thing core does with that value is call a method on it. On the front end that is ProfileStep::canComplete(), not completed(): Member::profileCompletion() tests canComplete() first (Member.php:5983), and ProfileStep.php:502-503 is $extension = $this->extension; then return $extension->canComplete( $member );, so the site fatals with Call to a member function canComplete() on null. ProfileStep.php:487, return $this->extension->completed( $this, $member );, fails the same way on any path that reaches it first. Because Member::profileCompletion() runs from the global template on every front-end page for any member with an unfinished profile, a single bad step row white-screens the whole front end for those members. Use a single-word extension key.

The same NULL arrives by a second route: getExtensionClass() also throws when the owning application is disabled (Application.php:551-554) or when recovery mode is on and the application is third-party (Application.php:556-559). Disabling your application while one of your steps still exists therefore produces the same fatal. Uninstalling is safe — Application::delete() calls ProfileStep::deleteByApplication() at Application.php:5473 — but disabling is not.

Two applications claiming the same action key

Action keys are a single global namespace with no prefixing convention: core uses basic_profile, social_login, custom, profile_photo and profile_fields, and Nexus uses customer_fields. Nothing detects a collision.

If two extensions return the same key, ProfileStep::actions() flattens them into one entry and the label of whichever came last wins (ProfileStep.php:306-312). Routing, however, is decided by findExtensionFromAction(), which returns the first extension whose actions() contains the key and stops (ProfileStep.php:743-752). So the administrator can see your label on a radio button that saves a step owned by somebody else's extension, and your completed() is never called. actionMultipleChoice() resolves first-match too (ProfileStep.php:352-361). Prefix your action keys with your application directory.

A missing abstract method, and where you find out

Omitting actions(), subActions(), completed() or wizard() is a compile-time fatal:

Fatal error: Class IPS\myapp\extensions\core\ProfileSteps\MySteps contains 4 abstract
methods and must therefore be declared abstract or implement the remaining methods
(IPS\Extensions\ProfileStepsAbstract::actions, ...)

It is raised when the autoloader first loads your class, inside the class_exists() call in Application::extensions(). The ProfileSteps list is built lazily, so this does not break the whole site: it breaks the ACP Profile Completion tab, the member completion wizard and the registration finish page, and leaves everything else working. Load AdminCP → Members → Profiles → Profile Completion once after adding the extension, as a smoke test.

Getting the shape wrong is quieter. ProfileStep::subActions() iterates your return value two levels deep (ProfileStep.php:330-336), so returning a flat array( 'key' => 'lang' ) makes the inner foreach run over a string. On PHP 8 that is a warning, not a fatal — the request continues and your action simply has no sub-action field. Check the PHP error log if a sub-action field is missing for no visible reason.

Two smaller shape traps. Application::constructExtensionClass() builds your object as new $classToUse( $checkAccess === TRUE ? Member::loggedIn() : ( $checkAccess === FALSE ? NULL : $checkAccess ) ) (Application.php:479), and because every ProfileSteps call site uses the default $checkAccess of TRUE that argument is Member::loggedIn() in practice; the abstract declares no constructor, so the argument is harmlessly ignored, but if you declare a constructor whose first parameter is not compatible with IPS\Member you will get a TypeError that constructExtensionClass() does not catch. And core's own implementations declare Member $member = NULL where the abstract says ?Member $member = NULL; these are compatible today but the implicitly-nullable form is deprecated in PHP 8.4, so write the explicit form. The Developer Center template does, though it aliases the class — use IPS\Member as MemberClass; and then ?MemberClass $member = NULL.

wizard() is called more often than you think

Not a failure, but a performance trap worth knowing about. Nothing caches the result. All three enumerating loops are written the same way — is_array( $extension::wizard() ) AND count( $extension::wizard() ) and then array_merge( $steps, $extension::wizard() ) — so each calls wizard() up to three times per extension per request: ProfileStep::getNextStep() (ProfileStep.php:244-247), and both wizard controllers (settings.php:2111-2113, register.php:441-443). Every core implementation loops ProfileStep::loadAll() inside it, and ProfileFields::wizard() issues a database query per step on top. Keep the work in wizard() to deciding which steps to return; put the expensive part inside the closure, which runs only for the step actually being displayed.

Note too that core never passes the $member argument — every call site is a bare wizard() — so in 5.0.19 your implementation always falls back to Member::loggedIn(). During the inline registration steps at register.php:256-262 that may still be a guest with member_id of 0, so guard for it.

A complete working example

Core's Editor extension is the smallest complete implementation in the suite and the closest to what a third-party extension usually wants: one action, its own completion table, no dependency on member columns. It powers the "Custom Message" step, where an administrator writes some text and the member has to click through it once. This is applications/core/extensions/core/ProfileSteps/Editor.php, abridged only by removing the licence header and the SUITE_UNIQUE_KEY guard, which you must keep.

namespace IPS\core\extensions\core\ProfileSteps;

use IPS\Db;
use IPS\Extensions\ProfileStepsAbstract;
use IPS\Helpers\Form;
use IPS\Lang;
use IPS\Member;
use IPS\Member\ProfileStep;
use IPS\Theme;
use function count;

class Editor extends ProfileStepsAbstract
{
	public static function actions(): array
	{
		return array( 'custom' => 'complete_profile_custom_editor' );
	}

	public static function subActions(): array
	{
		return array( 'custom' => array( 'complete_profile_custom_editor' ) );
	}

	public static function actionMultipleChoice( string $action ): ?bool
	{
		return NULL;
	}

	public static function allowMultiple() : array
	{
		return array( 'custom' );
	}

	public function completed( ProfileStep $step, Member $member = NULL ): bool
	{
		$memberId = $member ? $member->member_id : Member::loggedIn()->member_id;
		if( !$memberId )
		{
			return false;
		}

		return (bool) Db::i()->select( 'count(*)', 'core_profile_completion', array( 'member_id=? and step_id=?', $memberId, $step->id ) )->first();
	}

	public function postAcpSave( ProfileStep $step, array $values ) : void
	{
		Lang::saveCustom( 'core', "profile_step_subaction_custom_" . $step->id, $values['step_subcompletion_act'] );
	}

	public static function wizard( Member $member = NULL ): array|string
	{
		$member = $member ?: Member::loggedIn();
		$wizards = array();

		foreach( ProfileStep::loadAll() AS $step )
		{
			if ( $step->completion_act === 'custom' AND !$step->completed( $member ) )
			{
				$wizards[ $step->key ] = function( $data ) use ( $member, $step ) {
					$form = new Form( 'profile_profile_fields_' . $step->id, 'profile_complete_next' );

					$form->addHtml( Theme::i()->getTemplate( 'global', 'core', 'global' )->richText( $member->language()->addToStack( 'profile_step_subaction_custom_' . $step->id ), [ 'i-padding_3' ] ) );

					/* Because there are no form elements, $values is an empty array - that's ok and means the form was submitted, while FALSE means it wasn't */
					if ( ( $values = $form->values() ) !== FALSE )
					{
						/* Store the flag that the step is done */
						Db::i()->insert( 'core_profile_completion', array( 'member_id' => $member->member_id, 'step_id' => $step->id, 'completed' => time() ) );

						return $values;
					}

					return $form->customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'profileCompleteTemplate' ), $step );
				};
			}
		}

		if ( count( $wizards ) )
		{
			return $wizards;
		}

		return [];
	}

	public function onDelete( ProfileStep $step ) : void
	{
		Lang::deleteCustom( 'core', 'profile_step_subaction_custom' );
	}
}

Five things in that file are worth copying rather than inventing.

  • The wizard array is keyed by $step->key, and the loop filters on $step->completion_act === 'custom' and on !$step->completed( $member ). A step that is already done must not be returned, or the member is shown a form they have already submitted.
  • The closure returns an array to advance the wizard and a string of HTML otherwise. The comment about !== FALSE is there because this form has no fields, so a successful submission gives an empty array, which is falsy.
  • Completion is recorded in core_profile_completion (member_id, step_id, completed), a general-purpose table core provides for exactly this. Rows for a step are deleted with it by ProfileStep::delete() at ProfileStep.php:180. You do not have to use it — the other four implementations infer completion from member columns instead — but if you have nowhere else to store the fact, it is there.
  • customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'profileCompleteTemplate' ), $step ) is the form template that renders the step's title and description around your fields. All five implementations use it; a plain (string) $form would drop the administrator's wording.
  • 'profile_complete_next' is the submit-button language key used throughout the wizard.

The registration entry, at applications/core/data/extensions.json:296, is one line:

"Editor": "IPS\\core\\extensions\\core\\ProfileSteps\\Editor"

And note what this example does not declare: canBeRequired() is absent, so an administrator cannot make a custom-message step mandatory; canComplete(), url(), formatFormValues() and resync() are absent because the inherited no-op behaviour is correct here. Declare only what you need.

Verified against

Read from the source of Invision Community 5.0.19. Key files: system/Extensions/ProfileStepsAbstract.php, system/Member/ProfileStep.php, system/Member/Member.php (profileCompletion(), profileCompletionPercentage(), nextProfileStep()), system/Application/Application.php (allExtensions(), extensions(), constructExtensionClass(), getExtensionClass(), $caches), system/Helpers/Wizard/Wizard.php, applications/core/modules/admin/membersettings/profilecompletion.php, applications/core/modules/front/system/settings.php, applications/core/modules/front/system/register.php, applications/core/dev/html/front/global/globalTemplate.phtml and profileNextStep.phtml, applications/core/data/defaults/extensions/ProfileSteps.txt, the four implementations under applications/core/extensions/core/ProfileSteps/ and the one under applications/nexus/extensions/core/ProfileSteps/. The claim that url() has no caller rests on a full-source grep for every use of ProfileStep::get_extension(), ProfileStep::loadExtensionFromAction() and Application::allExtensions( 'core', 'ProfileSteps' ); it is a statement about 5.0.19 only. The exact PHP warning text for a mis-shaped subActions() return was reasoned from the foreach at ProfileStep.php:332 rather than reproduced on a running site, so treat the wording, though not the outcome, as unverified.


User Feedback

Recommended Comments

There are no comments to display.

Account

Navigation

Search

Search

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.