diff --git a/framework/core/js/src/common/components/ItemList.tsx b/framework/core/js/src/common/components/ItemList.tsx new file mode 100644 index 0000000000..8b29e6b38c --- /dev/null +++ b/framework/core/js/src/common/components/ItemList.tsx @@ -0,0 +1,55 @@ +import ItemListUtil from '../utils/ItemList'; +import Component from '../Component'; +import type Mithril from 'mithril'; +import listItems from '../helpers/listItems'; + +export interface IItemListAttrs { + /** Unique key for the list. Use the convention of `componentName.listName` */ + key: string; + /** The context of the list. Usually the component instance. Will be automatically set if not provided. */ + context?: any; + /** Optionally, the element tag to wrap each item in. Defaults to none. */ + wrapper?: string; +} + +export default class ItemList extends Component { + view(vnode: Mithril.Vnode) { + const items = this.items(vnode.children).toArray(); + + return vnode.attrs.wrapper ? listItems(items, vnode.attrs.wrapper) : items; + } + + items(children: Mithril.ChildArrayOrPrimitive | undefined): ItemListUtil { + const items = new ItemListUtil(); + + let priority = 10; + + this.validateChildren(children) + .reverse() + .forEach((child: Mithril.Vnode) => { + items.add(child.key!.toString(), child, (priority += 10)); + }); + + return items; + } + + private validateChildren(children: Mithril.ChildArrayOrPrimitive | undefined): Mithril.Vnode[] { + if (!children) return []; + + children = Array.isArray(children) ? children : [children]; + children = children.filter((child: Mithril.Children) => child !== null && child !== undefined); + + // It must be a Vnode array + children.forEach((child: Mithril.Children) => { + if (typeof child !== 'object' || !('tag' in child!)) { + throw new Error(`[${this.attrs.key}] The ItemList component requires a valid mithril Vnode array. Found: ${typeof child}.`); + } + + if (!child.key) { + throw new Error('The ItemList component requires a unique key for each child in the list.'); + } + }); + + return children as Mithril.Vnode[]; + } +} diff --git a/framework/core/js/src/common/extenders/ItemList.ts b/framework/core/js/src/common/extenders/ItemList.ts new file mode 100644 index 0000000000..9edd93d53e --- /dev/null +++ b/framework/core/js/src/common/extenders/ItemList.ts @@ -0,0 +1,89 @@ +import type IExtender from './IExtender'; +import type { IExtensionModule } from './IExtender'; +import type Application from '../Application'; +import type Mithril from 'mithril'; +import type { IItemObject } from '../utils/ItemList'; +import { extend } from '../extend'; +import ItemListComponent from '../components/ItemList'; + +type LazyContent = (context: T) => Mithril.Children; + +/** + * The `ItemList` extender allows you to add, remove, and replace items in an + * `ItemList` component. Each ItemList has a unique key, which is used to + * identify it. + * + * @example + * ```tsx + * import Extend from 'flarum/common/extenders'; + * + * export default [ + * new Extend.ItemList('PageStructure.mainItems') + * .add('test', (context) => app.forum.attribute('baseUrl'), 400) + * .setContent('hero', (context) =>
My new content
) + * .setPriority('hero', 0) + * .remove('hero') + * ] + * ``` + */ +export default class ItemList> implements IExtender { + protected key: string; + protected additions: Array>> = []; + protected removals: string[] = []; + protected contentReplacements: Record> = {}; + protected priorityReplacements: Record = {}; + + constructor(key: string) { + this.key = key; + } + + add(itemName: string, content: LazyContent, priority: number = 0) { + this.additions.push({ itemName, content, priority }); + + return this; + } + + remove(itemName: string) { + this.removals.push(itemName); + + return this; + } + + setContent(itemName: string, content: LazyContent) { + this.contentReplacements[itemName] = content; + + return this; + } + + setPriority(itemName: string, priority: number) { + this.priorityReplacements[itemName] = priority; + + return this; + } + + extend(app: Application, extension: IExtensionModule) { + const { key, additions, removals, contentReplacements, priorityReplacements } = this; + + extend(ItemListComponent.prototype, 'items', function (this: ItemListComponent, items) { + if (key !== this.attrs.key) return; + + const safeContent = (content: Mithril.Children) => (typeof content === 'string' ? [content] : content); + + for (const itemName of removals) { + items.remove(itemName); + } + + for (const { itemName, content, priority } of additions) { + items.add(itemName, safeContent(content(this.attrs.context)), priority); + } + + for (const [itemName, content] of Object.entries(contentReplacements)) { + items.setContent(itemName, safeContent(content(this.attrs.context))); + } + + for (const [itemName, priority] of Object.entries(priorityReplacements)) { + items.setPriority(itemName, priority); + } + }); + } +} diff --git a/framework/core/js/src/common/extenders/index.ts b/framework/core/js/src/common/extenders/index.ts index e2d5dda532..a989310a2d 100644 --- a/framework/core/js/src/common/extenders/index.ts +++ b/framework/core/js/src/common/extenders/index.ts @@ -2,12 +2,14 @@ import Model from './Model'; import PostTypes from './PostTypes'; import Routes from './Routes'; import Store from './Store'; +import ItemList from './ItemList'; const extenders = { Model, PostTypes, Routes, Store, + ItemList, }; export default extenders; diff --git a/framework/core/js/src/forum/components/DiscussionPage.tsx b/framework/core/js/src/forum/components/DiscussionPage.tsx index 343f56e7bd..1e564c43ac 100644 --- a/framework/core/js/src/forum/components/DiscussionPage.tsx +++ b/framework/core/js/src/forum/components/DiscussionPage.tsx @@ -5,7 +5,6 @@ import Page, { IPageAttrs } from '../../common/components/Page'; import ItemList from '../../common/utils/ItemList'; import DiscussionHero from './DiscussionHero'; import DiscussionListPane from './DiscussionListPane'; -import LoadingIndicator from '../../common/components/LoadingIndicator'; import SplitDropdown from '../../common/components/SplitDropdown'; import listItems from '../../common/helpers/listItems'; import DiscussionControls from '../utils/DiscussionControls'; diff --git a/framework/core/js/src/forum/components/PageStructure.tsx b/framework/core/js/src/forum/components/PageStructure.tsx index c9bf025d06..c349e7d376 100644 --- a/framework/core/js/src/forum/components/PageStructure.tsx +++ b/framework/core/js/src/forum/components/PageStructure.tsx @@ -2,8 +2,8 @@ import Component from '../../common/Component'; import type { ComponentAttrs } from '../../common/Component'; import type Mithril from 'mithril'; import classList from '../../common/utils/classList'; -import ItemList from '../../common/utils/ItemList'; import LoadingIndicator from '../../common/components/LoadingIndicator'; +import ItemList from '../../common/components/ItemList'; export interface PageStructureAttrs extends ComponentAttrs { hero?: () => Mithril.Children; @@ -21,73 +21,44 @@ export default class PageStructure{this.rootItems().toArray()}; - } - - rootItems(): ItemList { - const items = new ItemList(); - - items.add('pane', this.providedPane(), 100); - items.add('main', this.main(), 10); - - return items; - } - - mainItems(): ItemList { - const items = new ItemList(); - - items.add('hero', this.providedHero(), 100); - items.add('container', this.container(), 10); - - return items; - } - - loadingItems(): ItemList { - const items = new ItemList(); - - items.add('spinner', , 100); - - return items; - } - - main(): Mithril.Children { - return
{this.attrs.loading ? this.loadingItems().toArray() : this.mainItems().toArray()}
; - } - - containerItems(): ItemList { - const items = new ItemList(); - - items.add('sidebar', this.sidebar(), 100); - items.add('content', this.providedContent(), 10); - - return items; - } - - container(): Mithril.Children { - return
{this.containerItems().toArray()}
; - } - - sidebarItems(): ItemList { - const items = new ItemList(); - - items.add('sidebar', (this.attrs.sidebar && this.attrs.sidebar()) || null, 100); - - return items; - } - - sidebar(): Mithril.Children { - return
{this.sidebarItems().toArray()}
; - } - - providedPane(): Mithril.Children { - return
{(this.attrs.pane && this.attrs.pane()) || null}
; - } - - providedHero(): Mithril.Children { - return
{(this.attrs.hero && this.attrs.hero()) || null}
; - } - - providedContent(): Mithril.Children { - return
{this.content}
; + return ( +
+ +
+ {(this.attrs.pane && this.attrs.pane()) || null} +
+ +
+ {this.attrs.loading ? ( + + + + ) : ( + +
+ {(this.attrs.hero && this.attrs.hero()) || null} +
+ +
+
+ + {this.attrs.sidebar && ( +
+ {this.attrs.sidebar()} +
+ )} +
+
+ +
+ {this.content} +
+
+
+ )} +
+
+
+ ); } } diff --git a/framework/core/less/forum/DiscussionPage.less b/framework/core/less/forum/DiscussionPage.less index f4626d786c..c2b9c276a8 100644 --- a/framework/core/less/forum/DiscussionPage.less +++ b/framework/core/less/forum/DiscussionPage.less @@ -40,6 +40,10 @@ &-sidebar { margin-top: 0; + + &-main { + height: 100%; + } } } }