Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions framework/core/js/src/common/extenders/Conditional.ts
Original file line number Diff line number Diff line change
@@ -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<any>[] }[] = [];

/**
* 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<Tag>('acmeFeaturedTag'),
* ])
*/
public whenExtensionEnabled(extensionId: string, extenders: () => IExtender<any>[]): 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<string>('acmeCategoryLabel'),
* ])
*/
public whenExtensionDisabled(extensionId: string, extenders: () => IExtender<any>[]): 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<boolean>('acmeMentionsTag'),
* ])
*/
public whenExtensionsEnabled(extensionIds: string[], extenders: () => IExtender<any>[]): 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<string>('acmeCategoryLabel'),
* ])
*/
public whenExtensionsDisabled(extensionIds: string[], extenders: () => IExtender<any>[]): 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<any>[]): 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<boolean>('acmeIsLinked')]
* )
*/
public when(condition: boolean | ((app: Application) => boolean), extenders: () => IExtender<any>[]): 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);
}
}
}
}
}
2 changes: 2 additions & 0 deletions framework/core/js/src/common/extenders/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -16,6 +17,7 @@ const extenders = {
Notification,
ThemeMode,
Admin,
Conditional,
};

export default extenders;
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading