diff --git a/framework/core/js/src/common/extenders/Conditional.ts b/framework/core/js/src/common/extenders/Conditional.ts new file mode 100644 index 0000000000..f7247d35fa --- /dev/null +++ b/framework/core/js/src/common/extenders/Conditional.ts @@ -0,0 +1,129 @@ +import type Application from '../Application'; +import IExtender, { IExtensionModule } from './IExtender'; + +export default class Conditional implements IExtender { + protected conditions: { condition: boolean | ((app: Application) => boolean); extenders: () => IExtender[] }[] = []; + + /** + * Apply extenders only if an extension is enabled. + * + * Only extensions with JS for the current frontend can be detected. + * + * @param extensionId The ID of the extension. + * @param extenders A callback returning an array of extenders. + * + * @example + * new Extend.Conditional() + * .whenExtensionEnabled('flarum-tags', () => [ + * new Extend.Model(Discussion).hasOne('acmeFeaturedTag'), + * ]) + */ + public whenExtensionEnabled(extensionId: string, extenders: () => IExtender[]): this { + return this.when(() => extensionId in flarum.extensions, extenders); + } + + /** + * Apply extenders only if an extension is disabled. + * + * @param extensionId The ID of the extension. + * @param extenders A callback returning an array of extenders. + * + * @example + * new Extend.Conditional() + * .whenExtensionDisabled('flarum-tags', () => [ + * new Extend.Model(Discussion).attribute('acmeCategoryLabel'), + * ]) + */ + public whenExtensionDisabled(extensionId: string, extenders: () => IExtender[]): this { + return this.when(() => !(extensionId in flarum.extensions), extenders); + } + + /** + * Apply extenders only if all of the given extensions are enabled. + * + * @param extensionIds The IDs of the extensions. + * @param extenders A callback returning an array of extenders. + * + * @example + * new Extend.Conditional() + * .whenExtensionsEnabled(['flarum-tags', 'flarum-mentions'], () => [ + * new Extend.Model(Discussion).attribute('acmeMentionsTag'), + * ]) + */ + public whenExtensionsEnabled(extensionIds: string[], extenders: () => IExtender[]): this { + return this.when(() => extensionIds.every((extensionId) => extensionId in flarum.extensions), extenders); + } + + /** + * Apply extenders only if all of the given extensions are disabled. + * + * @param extensionIds The IDs of the extensions. + * @param extenders A callback returning an array of extenders. + * + * @example + * new Extend.Conditional() + * .whenExtensionsDisabled(['flarum-tags', 'flarum-mentions'], () => [ + * new Extend.Model(Discussion).attribute('acmeCategoryLabel'), + * ]) + */ + public whenExtensionsDisabled(extensionIds: string[], extenders: () => IExtender[]): this { + return this.when(() => extensionIds.every((extensionId) => !(extensionId in flarum.extensions)), extenders); + } + + /** + * Apply extenders only if a forum attribute matches an expected value. + * + * Uses strict comparison (===). The value is read from the forum payload, + * as `app.forum` doesn't exist yet when extenders are applied. + * + * @param key The name of the forum attribute. + * @param expected The expected value. + * @param extenders A callback returning an array of extenders. + * + * @example + * new Extend.Conditional() + * .whenForumAttribute('acmeAdvancedMode', true, () => [ + * new Extend.Routes().add('acme.advanced', '/acme/advanced', AdvancedPage), + * ]) + */ + public whenForumAttribute(key: string, expected: unknown, extenders: () => IExtender[]): this { + return this.when((app) => { + const forum = app.data.resources.find((resource) => resource.type === 'forums'); + + return forum?.attributes?.[key] === expected; + }, extenders); + } + + /** + * Apply extenders based on a condition. + * + * Conditions are checked before the application boots, so `app.forum` and + * `app.session` aren't available yet. Use `whenForumAttribute()` to check + * a forum attribute. + * + * @param condition A boolean, or a callback returning a boolean. + * @param extenders A callback returning an array of extenders. + * + * @example + * new Extend.Conditional() + * .when( + * () => 'flarum-tags' in flarum.extensions || 'flarum-mentions' in flarum.extensions, + * () => [new Extend.Model(Discussion).attribute('acmeIsLinked')] + * ) + */ + public when(condition: boolean | ((app: Application) => boolean), extenders: () => IExtender[]): this { + this.conditions.push({ condition, extenders }); + + return this; + } + + extend(app: Application, extension: IExtensionModule): void { + for (const { condition, extenders } of this.conditions) { + if (typeof condition === 'function' ? condition(app) : condition) { + for (const extender of extenders()) { + extender.extend(app, extension); + } + } + } + } +} diff --git a/framework/core/js/src/common/extenders/index.ts b/framework/core/js/src/common/extenders/index.ts index 417dcf524f..c3e80dadef 100644 --- a/framework/core/js/src/common/extenders/index.ts +++ b/framework/core/js/src/common/extenders/index.ts @@ -6,6 +6,7 @@ import Search from './Search'; import Notification from './Notification'; import ThemeMode from './ThemeMode'; import Admin from './Admin'; +import Conditional from './Conditional'; const extenders = { Model, @@ -16,6 +17,7 @@ const extenders = { Notification, ThemeMode, Admin, + Conditional, }; export default extenders; diff --git a/framework/core/js/tests/integration/common/extenders/Conditional.test.ts b/framework/core/js/tests/integration/common/extenders/Conditional.test.ts new file mode 100644 index 0000000000..6f3249c74a --- /dev/null +++ b/framework/core/js/tests/integration/common/extenders/Conditional.test.ts @@ -0,0 +1,187 @@ +import { jest } from '@jest/globals'; +import bootstrapForum from '@flarum/jest-config/src/bootstrap/forum'; +import Extend from '../../../../src/common/extenders'; +import app from '@flarum/core/src/forum/app'; + +beforeAll(() => bootstrapForum()); + +afterEach(() => { + delete flarum.extensions['acme-other']; + delete flarum.extensions['acme-another']; +}); + +describe('Conditional extender', () => { + const routes = (name: string) => () => [new Extend.Routes().add(name, `/${name}`, null)]; + + test('conditions are checked with the application before it boots', () => { + let forumDuringCheck: unknown = null; + + const condition = jest.fn((received: typeof app) => { + forumDuringCheck = received.forum; + + return true; + }); + + app.bootExtensions({ + test: { + extend: [ + new Extend.Conditional() + .when(condition, routes('beforeBoot')) + .whenForumAttribute('canEditUserCredentials', true, routes('attributeBeforeBoot')), + ], + }, + }); + + expect(condition).toHaveBeenCalledWith(app); + expect(forumDuringCheck).toBeUndefined(); + expect(app.routes).toHaveProperty('beforeBoot'); + expect(app.routes).toHaveProperty('attributeBeforeBoot'); + }); + + test('extenders apply when the condition is true', () => { + app.bootExtensions({ + test: { + extend: [new Extend.Conditional().when(true, routes('whenTrue'))], + }, + }); + + app.boot(); + + expect(app.routes).toHaveProperty('whenTrue'); + }); + + test('extenders do not apply when the condition is false', () => { + const extenders = jest.fn(routes('whenFalse')); + + app.bootExtensions({ + test: { + extend: [new Extend.Conditional().when(() => false, extenders)], + }, + }); + + app.boot(); + + expect(app.routes).not.toHaveProperty('whenFalse'); + expect(extenders).not.toHaveBeenCalled(); + }); + + test('extenders apply when an extension is enabled', () => { + flarum.extensions['acme-other'] = {}; + + app.bootExtensions({ + test: { + extend: [new Extend.Conditional().whenExtensionEnabled('acme-other', routes('enabled'))], + }, + }); + + app.boot(); + + expect(app.routes).toHaveProperty('enabled'); + }); + + test('extenders do not apply when an extension is disabled', () => { + app.bootExtensions({ + test: { + extend: [new Extend.Conditional().whenExtensionEnabled('acme-other', routes('notEnabled'))], + }, + }); + + app.boot(); + + expect(app.routes).not.toHaveProperty('notEnabled'); + }); + + test('extenders apply when an extension is disabled and expected to be', () => { + app.bootExtensions({ + test: { + extend: [new Extend.Conditional().whenExtensionDisabled('acme-other', routes('disabled'))], + }, + }); + + app.boot(); + + expect(app.routes).toHaveProperty('disabled'); + }); + + test('extenders apply when all extensions are enabled', () => { + flarum.extensions['acme-other'] = {}; + flarum.extensions['acme-another'] = {}; + + app.bootExtensions({ + test: { + extend: [new Extend.Conditional().whenExtensionsEnabled(['acme-other', 'acme-another'], routes('allEnabled'))], + }, + }); + + app.boot(); + + expect(app.routes).toHaveProperty('allEnabled'); + }); + + test('extenders apply when all extensions are disabled', () => { + app.bootExtensions({ + test: { + extend: [new Extend.Conditional().whenExtensionsDisabled(['acme-other', 'acme-another'], routes('allDisabled'))], + }, + }); + + app.boot(); + + expect(app.routes).toHaveProperty('allDisabled'); + }); + + test('extenders do not apply when only some extensions are enabled', () => { + flarum.extensions['acme-other'] = {}; + + app.bootExtensions({ + test: { + extend: [ + new Extend.Conditional() + .whenExtensionsEnabled(['acme-other', 'acme-another'], routes('someEnabled')) + .whenExtensionsDisabled(['acme-other', 'acme-another'], routes('someDisabled')), + ], + }, + }); + + app.boot(); + + expect(app.routes).not.toHaveProperty('someEnabled'); + expect(app.routes).not.toHaveProperty('someDisabled'); + }); + + test('extenders apply when a forum attribute matches', () => { + app.bootExtensions({ + test: { + extend: [new Extend.Conditional().whenForumAttribute('canEditUserCredentials', true, routes('attributeMatches'))], + }, + }); + + app.boot(); + + expect(app.routes).toHaveProperty('attributeMatches'); + }); + + test('forum attributes are compared strictly', () => { + app.bootExtensions({ + test: { + extend: [new Extend.Conditional().whenForumAttribute('canEditUserCredentials', 'true', routes('attributeLoose'))], + }, + }); + + app.boot(); + + expect(app.routes).not.toHaveProperty('attributeLoose'); + }); + + test('extenders do not apply when a forum attribute is missing', () => { + app.bootExtensions({ + test: { + extend: [new Extend.Conditional().whenForumAttribute('acmeMissing', true, routes('attributeMissing'))], + }, + }); + + app.boot(); + + expect(app.routes).not.toHaveProperty('attributeMissing'); + }); +});