From a53a0db2b7a79920039955322614326d9ed9aa66 Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Mon, 12 Dec 2022 10:44:33 +0100 Subject: [PATCH] feat(tags): admin tag selection component (reusable tag selection modal) (#3686) * chore: move `KeyboardNavigation` to `common` first * feat: exract reusable `TagSelectionModal` from `TagDiscussionModal` * fix: improve for generic use * feat: add select tags admin setting component --- .../js/src/forum/addComposerAutocomplete.js | 2 +- .../js/src/forum/addComposerAutocomplete.js | 2 +- extensions/tags/js/src/@types/shims.d.ts | 6 +- .../admin/addTagSelectionSettingComponent.tsx | 11 + .../js/src/admin/addTagsPermissionScope.tsx | 2 +- .../components/SelectTagsSettingComponent.tsx | 69 +++ .../tags/js/src/admin/components/TagsPage.js | 2 +- extensions/tags/js/src/admin/index.ts | 5 + extensions/tags/js/src/common/compat.js | 4 + .../common/components/TagSelectionModal.tsx | 483 ++++++++++++++++++ .../{forum => common}/states/TagListState.ts | 2 +- .../forum/components/TagDiscussionModal.tsx | 382 ++------------ extensions/tags/js/src/forum/index.ts | 3 +- .../TagSelectionModal.less} | 8 +- extensions/tags/less/common/common.less | 1 + extensions/tags/less/forum.less | 1 - extensions/tags/locale/en.yml | 21 +- framework/core/js/src/common/compat.ts | 2 + .../core/js/src/common/components/Modal.tsx | 5 +- .../utils/KeyboardNavigatable.ts | 0 framework/core/js/src/forum/compat.ts | 3 +- .../core/js/src/forum/components/Search.tsx | 2 +- 22 files changed, 652 insertions(+), 364 deletions(-) create mode 100644 extensions/tags/js/src/admin/addTagSelectionSettingComponent.tsx create mode 100644 extensions/tags/js/src/admin/components/SelectTagsSettingComponent.tsx create mode 100644 extensions/tags/js/src/common/components/TagSelectionModal.tsx rename extensions/tags/js/src/{forum => common}/states/TagListState.ts (94%) rename extensions/tags/less/{forum/TagDiscussionModal.less => common/TagSelectionModal.less} (95%) rename framework/core/js/src/{forum => common}/utils/KeyboardNavigatable.ts (100%) diff --git a/extensions/emoji/js/src/forum/addComposerAutocomplete.js b/extensions/emoji/js/src/forum/addComposerAutocomplete.js index 332a710c8..e11235a96 100644 --- a/extensions/emoji/js/src/forum/addComposerAutocomplete.js +++ b/extensions/emoji/js/src/forum/addComposerAutocomplete.js @@ -3,7 +3,7 @@ import emojiMap from 'simple-emoji-map'; import { extend } from 'flarum/common/extend'; import TextEditor from 'flarum/common/components/TextEditor'; import TextEditorButton from 'flarum/common/components/TextEditorButton'; -import KeyboardNavigatable from 'flarum/forum/utils/KeyboardNavigatable'; +import KeyboardNavigatable from 'flarum/common/utils/KeyboardNavigatable'; import AutocompleteDropdown from './fragments/AutocompleteDropdown'; import getEmojiIconCode from './helpers/getEmojiIconCode'; diff --git a/extensions/mentions/js/src/forum/addComposerAutocomplete.js b/extensions/mentions/js/src/forum/addComposerAutocomplete.js index 78588a4da..261c5f03d 100644 --- a/extensions/mentions/js/src/forum/addComposerAutocomplete.js +++ b/extensions/mentions/js/src/forum/addComposerAutocomplete.js @@ -7,7 +7,7 @@ import EditPostComposer from 'flarum/forum/components/EditPostComposer'; import avatar from 'flarum/common/helpers/avatar'; import usernameHelper from 'flarum/common/helpers/username'; import highlight from 'flarum/common/helpers/highlight'; -import KeyboardNavigatable from 'flarum/forum/utils/KeyboardNavigatable'; +import KeyboardNavigatable from 'flarum/common/utils/KeyboardNavigatable'; import { truncate } from 'flarum/common/utils/string'; import { throttle } from 'flarum/common/utils/throttleDebounce'; import Badge from 'flarum/common/components/Badge'; diff --git a/extensions/tags/js/src/@types/shims.d.ts b/extensions/tags/js/src/@types/shims.d.ts index d6d711e8f..b1056ac77 100644 --- a/extensions/tags/js/src/@types/shims.d.ts +++ b/extensions/tags/js/src/@types/shims.d.ts @@ -1,5 +1,5 @@ import type Tag from '../common/models/Tag'; -import type TagListState from '../forum/states/TagListState'; +import type TagListState from '../common/states/TagListState'; declare module 'flarum/forum/routes' { export interface ForumRoutes { @@ -7,8 +7,8 @@ declare module 'flarum/forum/routes' { } } -declare module 'flarum/forum/ForumApplication' { - export default interface ForumApplication { +declare module 'flarum/common/Application' { + export default interface Application { tagList: TagListState; } } diff --git a/extensions/tags/js/src/admin/addTagSelectionSettingComponent.tsx b/extensions/tags/js/src/admin/addTagSelectionSettingComponent.tsx new file mode 100644 index 000000000..b710140cf --- /dev/null +++ b/extensions/tags/js/src/admin/addTagSelectionSettingComponent.tsx @@ -0,0 +1,11 @@ +import { extend } from 'flarum/common/extend'; +import AdminPage from 'flarum/admin/components/AdminPage'; +import SelectTagsSettingComponent from './components/SelectTagsSettingComponent'; + +export default function () { + extend(AdminPage.prototype, 'customSettingComponents', function (items) { + items.add('flarum-tags.select-tags', (attrs) => { + return ; + }); + }); +} diff --git a/extensions/tags/js/src/admin/addTagsPermissionScope.tsx b/extensions/tags/js/src/admin/addTagsPermissionScope.tsx index 49040c823..af7f01bd9 100644 --- a/extensions/tags/js/src/admin/addTagsPermissionScope.tsx +++ b/extensions/tags/js/src/admin/addTagsPermissionScope.tsx @@ -17,7 +17,7 @@ export default function () { }); extend(PermissionGrid.prototype, 'oncreate', function () { - app.store.find('tags', {}).then(() => { + app.tagList.load().then(() => { this.loading = false; m.redraw(); diff --git a/extensions/tags/js/src/admin/components/SelectTagsSettingComponent.tsx b/extensions/tags/js/src/admin/components/SelectTagsSettingComponent.tsx new file mode 100644 index 000000000..ead367b58 --- /dev/null +++ b/extensions/tags/js/src/admin/components/SelectTagsSettingComponent.tsx @@ -0,0 +1,69 @@ +import app from 'flarum/admin/app'; +import Component from 'flarum/common/Component'; +import Button from 'flarum/common/components/Button'; +import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; +import TagSelectionModal from '../../common/components/TagSelectionModal'; +import tagsLabel from '../../common/helpers/tagsLabel'; + +import type { CommonSettingsItemOptions } from 'flarum/admin/components/AdminPage'; +import type Stream from 'flarum/common/utils/Stream'; +import type { ITagSelectionModalAttrs } from '../../common/components/TagSelectionModal'; +import type Tag from '../../common/models/Tag'; + +export interface SelectTagsSettingComponentOptions extends CommonSettingsItemOptions { + type: 'flarum-tags.select-tags'; + options?: ITagSelectionModalAttrs; +} + +export interface SelectTagsSettingComponentAttrs extends SelectTagsSettingComponentOptions { + settingValue: Stream; +} + +export default class SelectTagsSettingComponent< + CustomAttrs extends SelectTagsSettingComponentAttrs = SelectTagsSettingComponentAttrs +> extends Component { + protected tags: Tag[] = []; + protected loaded = false; + + view() { + const value = JSON.parse(this.attrs.settingValue() || '[]'); + + if (!this.loaded) { + app.tagList.load(['parent']).then((tags) => { + this.tags = tags.filter((tag) => value.includes(tag.id())); + this.loaded = true; + m.redraw(); + }); + } + + return ( +
+ + {this.attrs.help &&

{this.attrs.help}

} + {!this.loaded ? ( + + ) : ( + + )} +
+ ); + } +} diff --git a/extensions/tags/js/src/admin/components/TagsPage.js b/extensions/tags/js/src/admin/components/TagsPage.js index e437fb9cd..158265c2f 100644 --- a/extensions/tags/js/src/admin/components/TagsPage.js +++ b/extensions/tags/js/src/admin/components/TagsPage.js @@ -47,7 +47,7 @@ export default class TagsPage extends ExtensionPage { this.loading = true; - app.store.find('tags', { include: 'parent' }).then(() => { + app.tagList.load(['parent']).then(() => { this.loading = false; m.redraw(); diff --git a/extensions/tags/js/src/admin/index.ts b/extensions/tags/js/src/admin/index.ts index 016919f8d..99429068e 100644 --- a/extensions/tags/js/src/admin/index.ts +++ b/extensions/tags/js/src/admin/index.ts @@ -5,20 +5,25 @@ import addTagPermission from './addTagPermission'; import addTagsHomePageOption from './addTagsHomePageOption'; import addTagChangePermission from './addTagChangePermission'; import TagsPage from './components/TagsPage'; +import TagListState from '../common/states/TagListState'; app.initializers.add('flarum-tags', (app) => { app.store.models.tags = Tag; + app.tagList = new TagListState(); + app.extensionData.for('flarum-tags').registerPage(TagsPage); addTagsPermissionScope(); addTagPermission(); addTagsHomePageOption(); addTagChangePermission(); + addTagSelectionSettingComponent(); }); // Expose compat API import tagsCompat from './compat'; import { compat } from '@flarum/core/admin'; +import addTagSelectionSettingComponent from './addTagSelectionSettingComponent'; Object.assign(compat, tagsCompat); diff --git a/extensions/tags/js/src/common/compat.js b/extensions/tags/js/src/common/compat.js index 4ad3c42ad..b574e3d09 100644 --- a/extensions/tags/js/src/common/compat.js +++ b/extensions/tags/js/src/common/compat.js @@ -3,6 +3,8 @@ import Tag from './models/Tag'; import tagsLabel from './helpers/tagsLabel'; import tagIcon from './helpers/tagIcon'; import tagLabel from './helpers/tagLabel'; +import TagSelectionModal from './components/TagSelectionModal'; +import TagListState from './states/TagListState'; export default { 'tags/utils/sortTags': sortTags, @@ -10,4 +12,6 @@ export default { 'tags/helpers/tagsLabel': tagsLabel, 'tags/helpers/tagIcon': tagIcon, 'tags/helpers/tagLabel': tagLabel, + 'tags/components/TagSelectionModal': TagSelectionModal, + 'tags/states/TagListState': TagListState, }; diff --git a/extensions/tags/js/src/common/components/TagSelectionModal.tsx b/extensions/tags/js/src/common/components/TagSelectionModal.tsx new file mode 100644 index 000000000..9b61ad53a --- /dev/null +++ b/extensions/tags/js/src/common/components/TagSelectionModal.tsx @@ -0,0 +1,483 @@ +import app from 'flarum/common/app'; +import Button from 'flarum/common/components/Button'; +import classList from 'flarum/common/utils/classList'; +import extractText from 'flarum/common/utils/extractText'; +import highlight from 'flarum/common/helpers/highlight'; +import KeyboardNavigatable from 'flarum/common/utils/KeyboardNavigatable'; +import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; +import Modal from 'flarum/common/components/Modal'; +import Stream from 'flarum/common/utils/Stream'; + +import sortTags from '../utils/sortTags'; +import tagLabel from '../helpers/tagLabel'; +import tagIcon from '../helpers/tagIcon'; +import ToggleButton from '../../forum/components/ToggleButton'; + +import type Tag from '../models/Tag'; +import type { IInternalModalAttrs } from 'flarum/common/components/Modal'; +import type Mithril from 'mithril'; + +export interface ITagSelectionModalLimits { + /** Whether to allow bypassing the limits set here. This will show a toggle button to bypass limits. */ + allowBypassing?: boolean; + /** Maximum number of primary/secondary tags allowed. */ + max?: { + total?: number; + primary?: number; + secondary?: number; + }; + /** Minimum number of primary/secondary tags to be selected. */ + min?: { + total?: number; + primary?: number; + secondary?: number; + }; +} + +export interface ITagSelectionModalAttrs extends IInternalModalAttrs { + /** Custom modal className to use. */ + className?: string; + /** Modal title, defaults to 'Choose Tags'. */ + title?: string; + /** Initial tag selection value. */ + selectedTags?: Tag[]; + /** Limits set based on minimum and maximum number of primary/secondary tags that can be selected. */ + limits?: ITagSelectionModalLimits; + /** Whether to allow resetting the value. Defaults to true. */ + allowResetting?: boolean; + /** Whether to require the parent tag of a selected tag to be selected as well. */ + requireParentTag?: boolean; + /** Filter tags that can be selected. */ + selectableTags?: (tags: Tag[]) => Tag[]; + /** Whether a tag can be selected. */ + canSelect: (tag: Tag) => boolean; + /** Callback for when a tag is selected. */ + onSelect?: (tag: Tag, selected: Tag[]) => void; + /** Callback for when a tag is deselected. */ + onDeselect?: (tag: Tag, selected: Tag[]) => void; + /** Callback for when the selection is submitted. */ + onsubmit?: (selected: Tag[]) => void; +} + +export type ITagSelectionModalState = undefined; + +export default class TagSelectionModal< + CustomAttrs extends ITagSelectionModalAttrs = ITagSelectionModalAttrs, + CustomState extends ITagSelectionModalState = ITagSelectionModalState +> extends Modal { + protected loading = true; + protected tags!: Tag[]; + protected selected: Tag[] = []; + protected bypassReqs: boolean = false; + + protected filter = Stream(''); + protected focused = false; + protected navigator = new KeyboardNavigatable(); + protected indexTag?: Tag; + + static initAttrs(attrs: ITagSelectionModalAttrs) { + super.initAttrs(attrs); + + // Default values for optional attributes. + attrs.title ||= extractText(app.translator.trans('flarum-tags.lib.tag_selection_modal.title')); + attrs.canSelect ||= () => true; + attrs.allowResetting ??= true; + attrs.limits = { + min: { + total: attrs.limits?.min?.total ?? -Infinity, + primary: attrs.limits?.min?.primary ?? -Infinity, + secondary: attrs.limits?.min?.secondary ?? -Infinity, + }, + max: { + total: attrs.limits?.max?.total ?? Infinity, + primary: attrs.limits?.max?.primary ?? Infinity, + secondary: attrs.limits?.max?.secondary ?? Infinity, + }, + }; + + // Prevent illogical limits from being provided. + catchInvalidLimits(attrs.limits); + } + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + this.navigator + .onUp(() => this.setIndex(this.getCurrentNumericIndex() - 1, true)) + .onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true)) + .onSelect(this.select.bind(this)) + .onRemove(() => this.selected.splice(this.selected.length - 1, 1)); + + app.tagList.load(['parent']).then((tags) => { + this.loading = false; + + if (this.attrs.selectableTags) { + tags = this.attrs.selectableTags(tags); + } + + this.tags = sortTags(tags); + + if (this.attrs.selectedTags) { + this.attrs.selectedTags.map(this.addTag.bind(this)); + } + + this.indexTag = tags[0]; + + m.redraw(); + }); + } + + className() { + return classList('TagSelectionModal', this.attrs.className); + } + + title() { + return this.attrs.title; + } + + content() { + if (this.loading || !this.tags) { + return ; + } + + const filter = this.filter().toLowerCase(); + const primaryCount = this.primaryCount(); + const secondaryCount = this.secondaryCount(); + const tags = this.getFilteredTags(); + + const inputWidth = Math.max(extractText(this.getInstruction(primaryCount, secondaryCount)).length, this.filter().length); + + return [ +
+
+
+
this.$('.TagsInput input').focus()}> + + {this.selected.map((tag) => ( + { + this.removeTag(tag); + this.onready(); + }} + > + {tagLabel(tag)} + + ))} + + (this.focused = true)} + onblur={() => (this.focused = false)} + /> +
+
+
+ +
+
+
, + +
+
    + {tags.map((tag) => ( +
  • (this.indexTag = tag)} + onclick={this.toggleTag.bind(this, tag)} + > + {tagIcon(tag)} + {highlight(tag.name(), filter)} + {tag.description() ? {tag.description()} : ''} +
  • + ))} +
+ {this.attrs.limits!.allowBypassing && ( +
+ (this.bypassReqs = !this.bypassReqs)} isToggled={this.bypassReqs}> + {app.translator.trans('flarum-tags.lib.tag_selection_modal.bypass_requirements')} + +
+ )} +
, + ]; + } + + /** + * Filters the available tags on every state change. + */ + private getFilteredTags(): Tag[] { + const filter = this.filter().toLowerCase(); + const primaryCount = this.primaryCount(); + const secondaryCount = this.secondaryCount(); + let tags = this.tags; + + if (this.attrs.requireParentTag) { + // Filter out all child tags whose parents have not been selected. This + // makes it impossible to select a child if its parent hasn't been selected. + tags = tags.filter((tag) => { + const parent = tag.parent(); + return parent !== null && (parent === false || this.selected.includes(parent)); + }); + } + + if (!this.bypassReqs) { + // If we reached the total maximum number of tags, we can't select anymore. + if (this.selected.length >= this.attrs.limits!.max!.total!) { + tags = tags.filter((tag) => this.selected.includes(tag)); + } + // If the number of selected primary/secondary tags is at the maximum, then + // we'll filter out all other tags of that type. + else { + if (primaryCount >= this.attrs.limits!.max!.primary!) { + tags = tags.filter((tag) => !tag.isPrimary() || this.selected.includes(tag)); + } + if (secondaryCount >= this.attrs.limits!.max!.secondary!) { + tags = tags.filter((tag) => tag.isPrimary() || this.selected.includes(tag)); + } + } + } + + // If the user has entered text in the filter input, then filter by tags + // whose name matches what they've entered. + if (filter) { + tags = tags.filter((tag) => tag.name().substring(0, filter.length).toLowerCase() === filter); + } + + if (!this.indexTag || !tags.includes(this.indexTag)) this.indexTag = tags[0]; + + return tags; + } + + /** + * Counts the number of selected primary tags. + */ + protected primaryCount(): number { + return this.selected.filter((tag) => tag.isPrimary()).length; + } + + /** + * Counts the number of selected secondary tags. + */ + protected secondaryCount(): number { + return this.selected.filter((tag) => !tag.isPrimary()).length; + } + + /** + * Validates the number of selected primary/secondary tags against the set min max limits. + */ + protected meetsRequirements(primaryCount: number, secondaryCount: number) { + if (this.bypassReqs || (this.attrs.allowResetting && this.selected.length === 0)) { + return true; + } + + if (this.selected.length < this.attrs.limits!.min!.total!) { + return false; + } + + return primaryCount >= this.attrs.limits!.min!.primary! && secondaryCount >= this.attrs.limits!.min!.secondary!; + } + + /** + * Add the given tag to the list of selected tags. + */ + protected addTag(tag: Tag | undefined) { + if (!tag || !this.attrs.canSelect(tag)) return; + + if (this.attrs.onSelect) { + this.attrs.onSelect(tag, this.selected); + } + + // If this tag has a parent, we'll also need to add the parent tag to the + // selected list if it's not already in there. + if (this.attrs.requireParentTag) { + const parent = tag.parent(); + if (parent && !this.selected.includes(parent)) { + this.selected.push(parent); + } + } + + if (!this.selected.includes(tag)) { + this.selected.push(tag); + } + } + + /** + * Remove the given tag from the list of selected tags. + */ + protected removeTag(tag: Tag) { + const index = this.selected.indexOf(tag); + + if (index !== -1) { + this.selected.splice(index, 1); + + // Look through the list of selected tags for any tags which have the tag + // we just removed as their parent. We'll need to remove them too. + if (this.attrs.requireParentTag) { + this.selected.filter((t) => t.parent() === tag).forEach(this.removeTag.bind(this)); + } + + if (this.attrs.onDeselect) { + this.attrs.onDeselect(tag, this.selected); + } + } + } + + protected toggleTag(tag: Tag) { + // Won't happen, needed for type safety. + if (!this.tags) return; + + if (this.selected.includes(tag)) { + this.removeTag(tag); + } else { + this.addTag(tag); + } + + if (this.filter()) { + this.filter(''); + this.indexTag = this.tags[0]; + } + + this.onready(); + } + + /** + * Gives human text instructions based on the current number of selected tags and set limits. + */ + protected getInstruction(primaryCount: number, secondaryCount: number) { + if (this.bypassReqs) { + return ''; + } + + if (primaryCount < this.attrs.limits!.min!.primary!) { + const remaining = this.attrs.limits!.min!.primary! - primaryCount; + return extractText(app.translator.trans('flarum-tags.lib.tag_selection_modal.choose_primary_placeholder', { count: remaining })); + } else if (secondaryCount < this.attrs.limits!.min!.secondary!) { + const remaining = this.attrs.limits!.min!.secondary! - secondaryCount; + return extractText(app.translator.trans('flarum-tags.lib.tag_selection_modal.choose_secondary_placeholder', { count: remaining })); + } else if (this.selected.length < this.attrs.limits!.min!.total!) { + const remaining = this.attrs.limits!.min!.total! - this.selected.length; + return extractText(app.translator.trans('flarum-tags.lib.tag_selection_modal.choose_tags_placeholder', { count: remaining })); + } + + return ''; + } + + /** + * Submit tag selection. + */ + onsubmit(e: SubmitEvent) { + e.preventDefault(); + + if (this.attrs.onsubmit) this.attrs.onsubmit(this.selected); + + this.hide(); + } + + protected select(e: KeyboardEvent) { + // Ctrl + Enter submits the selection, just Enter completes the current entry + if (e.metaKey || e.ctrlKey || (this.indexTag && this.selected.includes(this.indexTag))) { + if (this.selected.length) { + // The DOM submit method doesn't emit a `submit event, so we + // simulate a manual submission so our `onsubmit` logic is run. + this.$('button[type="submit"]').click(); + } + } else if (this.indexTag) { + this.getItem(this.indexTag)[0].dispatchEvent(new Event('click')); + } + } + + protected selectableItems() { + return this.$('.TagSelectionModal-list > li'); + } + + protected getCurrentNumericIndex() { + if (!this.indexTag) return -1; + + return this.selectableItems().index(this.getItem(this.indexTag)); + } + + protected getItem(selectedTag: Tag) { + return this.selectableItems().filter(`[data-index="${selectedTag.id()}"]`); + } + + protected setIndex(index: number, scrollToItem: boolean) { + const $items = this.selectableItems(); + const $dropdown = $items.parent(); + + if (index < 0) { + index = $items.length - 1; + } else if (index >= $items.length) { + index = 0; + } + + const $item = $items.eq(index); + + this.indexTag = app.store.getById('tags', $item.attr('data-index')!); + + m.redraw(); + + if (scrollToItem && this.indexTag) { + const dropdownScroll = $dropdown.scrollTop()!; + const dropdownTop = $dropdown.offset()!.top; + const dropdownBottom = dropdownTop + $dropdown.outerHeight()!; + const itemTop = $item.offset()!.top; + const itemBottom = itemTop + $item.outerHeight()!; + + let scrollTop; + if (itemTop < dropdownTop) { + scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10); + } else if (itemBottom > dropdownBottom) { + scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10); + } + + if (typeof scrollTop !== 'undefined') { + $dropdown.stop(true).animate({ scrollTop }, 100); + } + } + } +} + +/** + * Catch invalid limits provided to the tag selection modal. + */ +function catchInvalidLimits(limits: ITagSelectionModalLimits) { + if (limits.min!.primary! > limits.max!.primary!) { + throw new Error('The minimum number of primary tags allowed cannot be more than the maximum number of primary tags allowed.'); + } + + if (limits.min!.secondary! > limits.max!.secondary!) { + throw new Error('The minimum number of secondary tags allowed cannot be more than the maximum number of secondary tags allowed.'); + } + + if (limits.min!.total! > limits.max!.primary! + limits.max!.secondary!) { + throw new Error('The minimum number of tags allowed cannot be more than the maximum number of primary and secondary tags allowed together.'); + } + + if (limits.max!.total! < limits.min!.primary! + limits.min!.secondary!) { + throw new Error('The maximum number of tags allowed cannot be less than the minimum number of primary and secondary tags allowed together.'); + } + + if (limits.min!.total! > limits.max!.total!) { + throw new Error('The minimum number of tags allowed cannot be more than the maximum number of tags allowed.'); + } +} diff --git a/extensions/tags/js/src/forum/states/TagListState.ts b/extensions/tags/js/src/common/states/TagListState.ts similarity index 94% rename from extensions/tags/js/src/forum/states/TagListState.ts rename to extensions/tags/js/src/common/states/TagListState.ts index c616596d4..e5a1938b6 100644 --- a/extensions/tags/js/src/forum/states/TagListState.ts +++ b/extensions/tags/js/src/common/states/TagListState.ts @@ -1,4 +1,4 @@ -import app from 'flarum/forum/app'; +import app from 'flarum/common/app'; import type Tag from '../../common/models/Tag'; export default class TagListState { diff --git a/extensions/tags/js/src/forum/components/TagDiscussionModal.tsx b/extensions/tags/js/src/forum/components/TagDiscussionModal.tsx index 4a8eaffd7..82728c35b 100644 --- a/extensions/tags/js/src/forum/components/TagDiscussionModal.tsx +++ b/extensions/tags/js/src/forum/components/TagDiscussionModal.tsx @@ -1,362 +1,62 @@ import app from 'flarum/forum/app'; -import type Mithril from 'mithril'; -import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; import DiscussionPage from 'flarum/forum/components/DiscussionPage'; -import Button from 'flarum/common/components/Button'; -import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; -import highlight from 'flarum/common/helpers/highlight'; import classList from 'flarum/common/utils/classList'; import extractText from 'flarum/common/utils/extractText'; -import KeyboardNavigatable from 'flarum/forum/utils/KeyboardNavigatable'; -import Stream from 'flarum/common/utils/Stream'; -import Discussion from 'flarum/common/models/Discussion'; -import tagLabel from '../../common/helpers/tagLabel'; -import tagIcon from '../../common/helpers/tagIcon'; -import sortTags from '../../common/utils/sortTags'; import getSelectableTags from '../utils/getSelectableTags'; -import ToggleButton from './ToggleButton'; -import Tag from '../../common/models/Tag'; +import TagSelectionModal, { ITagSelectionModalAttrs } from '../../common/components/TagSelectionModal'; -export interface TagDiscussionModalAttrs extends IInternalModalAttrs { +import type Discussion from 'flarum/common/models/Discussion'; +import type Tag from '../../common/models/Tag'; + +export interface TagDiscussionModalAttrs extends ITagSelectionModalAttrs { discussion?: Discussion; - selectedTags?: Tag[]; - onsubmit?: (tags: Tag[]) => {}; } -export default class TagDiscussionModal extends Modal { - tagsLoading = true; +export default class TagDiscussionModal extends TagSelectionModal { + static initAttrs(attrs: TagDiscussionModalAttrs) { + super.initAttrs(attrs); - selected: Tag[] = []; - filter = Stream(''); - focused = false; - - minPrimary = app.forum.attribute('minPrimaryTags'); - maxPrimary = app.forum.attribute('maxPrimaryTags'); - minSecondary = app.forum.attribute('minSecondaryTags'); - maxSecondary = app.forum.attribute('maxSecondaryTags'); - - bypassReqs = false; - - navigator = new KeyboardNavigatable(); - - tags?: Tag[]; - - selectedTag?: Tag; - oninit(vnode: Mithril.Vnode) { - super.oninit(vnode); - - this.navigator - .onUp(() => this.setIndex(this.getCurrentNumericIndex() - 1, true)) - .onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true)) - .onSelect(this.select.bind(this)) - .onRemove(() => this.selected.splice(this.selected.length - 1, 1)); - - app.tagList.load(['parent']).then(() => { - this.tagsLoading = false; - - const tags = sortTags(getSelectableTags(this.attrs.discussion)); - this.tags = tags; - - const discussionTags = this.attrs.discussion?.tags(); - if (this.attrs.selectedTags) { - this.attrs.selectedTags.map(this.addTag.bind(this)); - } else if (discussionTags) { - discussionTags.forEach((tag) => tag && this.addTag(tag)); - } - - this.selectedTag = tags[0]; - - m.redraw(); - }); - } - - primaryCount() { - return this.selected.filter((tag) => tag.isPrimary()).length; - } - - secondaryCount() { - return this.selected.filter((tag) => !tag.isPrimary()).length; - } - - /** - * Add the given tag to the list of selected tags. - */ - addTag(tag: Tag) { - if (!tag.canStartDiscussion()) return; - - // If this tag has a parent, we'll also need to add the parent tag to the - // selected list if it's not already in there. - const parent = tag.parent(); - if (parent && !this.selected.includes(parent)) { - this.selected.push(parent); - } - - if (!this.selected.includes(tag)) { - this.selected.push(tag); - } - } - - /** - * Remove the given tag from the list of selected tags. - */ - removeTag(tag: Tag) { - const index = this.selected.indexOf(tag); - if (index !== -1) { - this.selected.splice(index, 1); - - // Look through the list of selected tags for any tags which have the tag - // we just removed as their parent. We'll need to remove them too. - this.selected.filter((selected) => selected.parent() === tag).forEach(this.removeTag.bind(this)); - } - } - - className() { - return 'TagDiscussionModal'; - } - - title() { - return this.attrs.discussion - ? app.translator.trans('flarum-tags.forum.choose_tags.edit_title', { title: {this.attrs.discussion.title()} }) + const title = attrs.discussion + ? app.translator.trans('flarum-tags.forum.choose_tags.edit_title', { title: {attrs.discussion.title()} }) : app.translator.trans('flarum-tags.forum.choose_tags.title'); - } - getInstruction(primaryCount: number, secondaryCount: number) { - if (this.bypassReqs) { - return ''; - } + attrs.className = classList(attrs.className, 'TagDiscussionModal'); + attrs.title = extractText(title); + attrs.allowResetting = !!app.forum.attribute('canBypassTagCounts'); + attrs.limits = { + allowBypassing: attrs.allowResetting, + max: { + primary: app.forum.attribute('minPrimaryTags'), + secondary: app.forum.attribute('maxSecondaryTags'), + }, + min: { + primary: app.forum.attribute('maxPrimaryTags'), + secondary: app.forum.attribute('minSecondaryTags'), + }, + }; + attrs.requireParentTag = true; + attrs.selectableTags = () => getSelectableTags(attrs.discussion); + attrs.selectedTags ??= (attrs.discussion?.tags() as Tag[]) || []; + attrs.canSelect = (tag) => tag.canStartDiscussion(); - if (primaryCount < this.minPrimary) { - const remaining = this.minPrimary - primaryCount; - return app.translator.trans('flarum-tags.forum.choose_tags.choose_primary_placeholder', { count: remaining }); - } else if (secondaryCount < this.minSecondary) { - const remaining = this.minSecondary - secondaryCount; - return app.translator.trans('flarum-tags.forum.choose_tags.choose_secondary_placeholder', { count: remaining }); - } + const suppliedOnsubmit = attrs.onsubmit || null; - return ''; - } + // Save changes. + attrs.onsubmit = function (tags) { + const discussion = attrs.discussion; - content() { - if (this.tagsLoading || !this.tags) { - return ; - } + if (discussion) { + discussion.save({ relationships: { tags } }).then(() => { + if (app.current.matches(DiscussionPage)) { + app.current.get('stream').update(); + } - let tags = this.tags; - const filter = this.filter().toLowerCase(); - const primaryCount = this.primaryCount(); - const secondaryCount = this.secondaryCount(); - - // Filter out all child tags whose parents have not been selected. This - // makes it impossible to select a child if its parent hasn't been selected. - tags = tags.filter((tag) => { - const parent = tag.parent(); - return parent !== null && (parent === false || this.selected.includes(parent)); - }); - - // If the number of selected primary/secondary tags is at the maximum, then - // we'll filter out all other tags of that type. - if (primaryCount >= this.maxPrimary && !this.bypassReqs) { - tags = tags.filter((tag) => !tag.isPrimary() || this.selected.includes(tag)); - } - - if (secondaryCount >= this.maxSecondary && !this.bypassReqs) { - tags = tags.filter((tag) => tag.isPrimary() || this.selected.includes(tag)); - } - - // If the user has entered text in the filter input, then filter by tags - // whose name matches what they've entered. - if (filter) { - tags = tags.filter((tag) => tag.name().substr(0, filter.length).toLowerCase() === filter); - } - - if (!this.selectedTag || !tags.includes(this.selectedTag)) this.selectedTag = tags[0]; - - const inputWidth = Math.max(extractText(this.getInstruction(primaryCount, secondaryCount)).length, this.filter().length); - - return [ -
-
-
-
this.$('.TagsInput input').focus()}> - - {this.selected.map((tag) => ( - { - this.removeTag(tag); - this.onready(); - }} - > - {tagLabel(tag)} - - ))} - - (this.focused = true)} - onblur={() => (this.focused = false)} - /> -
-
-
- -
-
-
, - -
-
    - {tags - .filter((tag) => filter || !tag.parent() || this.selected.includes(tag.parent() as Tag)) - .map((tag) => ( -
  • (this.selectedTag = tag)} - onclick={this.toggleTag.bind(this, tag)} - > - {tagIcon(tag)} - {highlight(tag.name(), filter)} - {tag.description() ? {tag.description()} : ''} -
  • - ))} -
- {!!app.forum.attribute('canBypassTagCounts') && ( -
- (this.bypassReqs = !this.bypassReqs)} isToggled={this.bypassReqs}> - {app.translator.trans('flarum-tags.forum.choose_tags.bypass_requirements')} - -
- )} -
, - ]; - } - - meetsRequirements(primaryCount: number, secondaryCount: number) { - if (this.bypassReqs) { - return true; - } - - return primaryCount >= this.minPrimary && secondaryCount >= this.minSecondary; - } - - toggleTag(tag: Tag) { - // Won't happen, needed for type safety. - if (!this.tags) return; - - if (this.selected.includes(tag)) { - this.removeTag(tag); - } else { - this.addTag(tag); - } - - if (this.filter()) { - this.filter(''); - this.selectedTag = this.tags[0]; - } - - this.onready(); - } - - select(e: KeyboardEvent) { - // Ctrl + Enter submits the selection, just Enter completes the current entry - if (e.metaKey || e.ctrlKey || (this.selectedTag && this.selected.includes(this.selectedTag))) { - if (this.selected.length) { - // The DOM submit method doesn't emit a `submit event, so we - // simulate a manual submission so our `onsubmit` logic is run. - this.$('button[type="submit"]').click(); - } - } else if (this.selectedTag) { - this.getItem(this.selectedTag)[0].dispatchEvent(new Event('click')); - } - } - - selectableItems() { - return this.$('.TagDiscussionModal-list > li'); - } - - getCurrentNumericIndex() { - if (!this.selectedTag) return -1; - - return this.selectableItems().index(this.getItem(this.selectedTag)); - } - - getItem(selectedTag: Tag) { - return this.selectableItems().filter(`[data-index="${selectedTag.id()}"]`); - } - - setIndex(index: number, scrollToItem: boolean) { - const $items = this.selectableItems(); - const $dropdown = $items.parent(); - - if (index < 0) { - index = $items.length - 1; - } else if (index >= $items.length) { - index = 0; - } - - const $item = $items.eq(index); - - this.selectedTag = app.store.getById('tags', $item.attr('data-index')!); - - m.redraw(); - - if (scrollToItem && this.selectedTag) { - const dropdownScroll = $dropdown.scrollTop()!; - const dropdownTop = $dropdown.offset()!.top; - const dropdownBottom = dropdownTop + $dropdown.outerHeight()!; - const itemTop = $item.offset()!.top; - const itemBottom = itemTop + $item.outerHeight()!; - - let scrollTop; - if (itemTop < dropdownTop) { - scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10); - } else if (itemBottom > dropdownBottom) { - scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10); + m.redraw(); + }); } - if (typeof scrollTop !== 'undefined') { - $dropdown.stop(true).animate({ scrollTop }, 100); - } - } - } - - onsubmit(e: SubmitEvent) { - e.preventDefault(); - - const discussion = this.attrs.discussion; - const tags = this.selected; - - if (discussion) { - discussion.save({ relationships: { tags } }).then(() => { - if (app.current.matches(DiscussionPage)) { - app.current.get('stream').update(); - } - m.redraw(); - }); - } - - if (this.attrs.onsubmit) this.attrs.onsubmit(tags); - - this.hide(); + if (suppliedOnsubmit) suppliedOnsubmit(tags); + }; } } diff --git a/extensions/tags/js/src/forum/index.ts b/extensions/tags/js/src/forum/index.ts index b314a0487..0087ed79d 100644 --- a/extensions/tags/js/src/forum/index.ts +++ b/extensions/tags/js/src/forum/index.ts @@ -3,12 +3,11 @@ import Model from 'flarum/common/Model'; import Discussion from 'flarum/common/models/Discussion'; import IndexPage from 'flarum/forum/components/IndexPage'; +import TagListState from '../common/states/TagListState'; import Tag from '../common/models/Tag'; import TagsPage from './components/TagsPage'; import DiscussionTaggedPost from './components/DiscussionTaggedPost'; -import TagListState from './states/TagListState'; - import addTagList from './addTagList'; import addTagFilter from './addTagFilter'; import addTagLabels from './addTagLabels'; diff --git a/extensions/tags/less/forum/TagDiscussionModal.less b/extensions/tags/less/common/TagSelectionModal.less similarity index 95% rename from extensions/tags/less/forum/TagDiscussionModal.less rename to extensions/tags/less/common/TagSelectionModal.less index f71acf31a..788b3fbe8 100644 --- a/extensions/tags/less/forum/TagDiscussionModal.less +++ b/extensions/tags/less/common/TagSelectionModal.less @@ -1,4 +1,4 @@ -.TagDiscussionModal { +.TagSelectionModal { @media @tablet-up { .Modal-header { background: @control-bg; @@ -29,16 +29,16 @@ } @media @tablet, @desktop, @desktop-hd { - .TagDiscussionModal-form { + .TagSelectionModal-form { display: table; width: 100%; } - .TagDiscussionModal-form-input { + .TagSelectionModal-form-input { display: table-cell; width: 100%; vertical-align: top; } - .TagDiscussionModal-form-submit { + .TagSelectionModal-form-submit { display: table-cell; padding-left: 15px; } diff --git a/extensions/tags/less/common/common.less b/extensions/tags/less/common/common.less index 809308389..e58ceace4 100644 --- a/extensions/tags/less/common/common.less +++ b/extensions/tags/less/common/common.less @@ -1,3 +1,4 @@ @import "root"; @import "TagLabel"; @import "TagIcon"; +@import "TagSelectionModal"; diff --git a/extensions/tags/less/forum.less b/extensions/tags/less/forum.less index f1a34a8e2..15b2ba32d 100644 --- a/extensions/tags/less/forum.less +++ b/extensions/tags/less/forum.less @@ -1,7 +1,6 @@ @import "common/common"; @import "forum/TagCloud"; -@import "forum/TagDiscussionModal"; @import "forum/TagHero"; @import "forum/TagTiles"; @import "forum/ToggleButton"; diff --git a/extensions/tags/locale/en.yml b/extensions/tags/locale/en.yml index b7656c550..2ede1863a 100644 --- a/extensions/tags/locale/en.yml +++ b/extensions/tags/locale/en.yml @@ -38,6 +38,10 @@ flarum-tags: restrict_by_tag_heading: Restrict by Tag tag_discussions_label: Tag discussions + # These translations are used in the Tags custom settings component. + settings: + button_text: => flarum-tags.ref.choose_tags + # These translations are used in the Tag Settings modal dialog. tag_settings: range_separator_text: " to " @@ -66,16 +70,12 @@ flarum-tags: # These translations are used by the Choose Tags modal dialog. choose_tags: - bypass_requirements: Bypass tag requirements - choose_primary_placeholder: "{count, plural, one {Choose a primary tag} other {Choose # primary tags}}" - choose_secondary_placeholder: "{count, plural, one {Choose 1 more tag} other {Choose # more tags}}" edit_title: "Edit Tags for {title}" - submit_button: => core.ref.okay title: Choose Tags for Your Discussion # These translations are used by the composer when starting a discussion. composer_discussion: - choose_tags_link: Choose Tags + choose_tags_link: => flarum-tags.ref.choose_tags # These translations are used by the discussion control buttons. discussion_controls: @@ -107,11 +107,22 @@ flarum-tags: # This translation is displayed in place of the name of a tag that's been deleted. deleted_tag_text: Deleted + # These translations are used in the tag selection modal. + tag_selection_modal: + bypass_requirements: Bypass tag requirements + choose_primary_placeholder: "{count, plural, one {Choose a primary tag} other {Choose # primary tags}}" + choose_secondary_placeholder: => flarum-tags.ref.choose_tags_placeholder + choose_tags_placeholder: => flarum-tags.ref.choose_tags_placeholder + submit_button: => core.ref.okay + title: => flarum-tags.ref.choose_tags + ## # REUSED TRANSLATIONS - These keys should not be used directly in code! ## # Translations in this namespace are referenced by two or more unique keys. ref: + choose_tags: Choose Tags + choose_tags_placeholder: "{count, plural, one {Choose 1 more tag} other {Choose # more tags}}" name: Name tags: Tags diff --git a/framework/core/js/src/common/compat.ts b/framework/core/js/src/common/compat.ts index b63c028ee..394dc6fd7 100644 --- a/framework/core/js/src/common/compat.ts +++ b/framework/core/js/src/common/compat.ts @@ -4,6 +4,7 @@ import Session from './Session'; import Store from './Store'; import BasicEditorDriver from './utils/BasicEditorDriver'; import evented from './utils/evented'; +import KeyboardNavigatable from './utils/KeyboardNavigatable'; import liveHumanTimes from './utils/liveHumanTimes'; import ItemList from './utils/ItemList'; import mixin from './utils/mixin'; @@ -94,6 +95,7 @@ export default { Store: Store, 'utils/BasicEditorDriver': BasicEditorDriver, 'utils/evented': evented, + 'utils/KeyboardNavigatable': KeyboardNavigatable, 'utils/liveHumanTimes': liveHumanTimes, 'utils/ItemList': ItemList, 'utils/mixin': mixin, diff --git a/framework/core/js/src/common/components/Modal.tsx b/framework/core/js/src/common/components/Modal.tsx index 4cde799b0..d34f55044 100644 --- a/framework/core/js/src/common/components/Modal.tsx +++ b/framework/core/js/src/common/components/Modal.tsx @@ -30,7 +30,10 @@ export interface IDismissibleOptions { * The `Modal` component displays a modal dialog, wrapped in a form. Subclasses * should implement the `className`, `title`, and `content` methods. */ -export default abstract class Modal extends Component { +export default abstract class Modal extends Component< + ModalAttrs, + CustomState +> { // TODO: [Flarum 2.0] remove `isDismissible` static attribute /** * Determine whether or not the modal should be dismissible via an 'x' button. diff --git a/framework/core/js/src/forum/utils/KeyboardNavigatable.ts b/framework/core/js/src/common/utils/KeyboardNavigatable.ts similarity index 100% rename from framework/core/js/src/forum/utils/KeyboardNavigatable.ts rename to framework/core/js/src/common/utils/KeyboardNavigatable.ts diff --git a/framework/core/js/src/forum/compat.ts b/framework/core/js/src/forum/compat.ts index 2f14bca5e..47f0ac96b 100644 --- a/framework/core/js/src/forum/compat.ts +++ b/framework/core/js/src/forum/compat.ts @@ -1,7 +1,7 @@ import compat from '../common/compat'; import PostControls from './utils/PostControls'; -import KeyboardNavigatable from './utils/KeyboardNavigatable'; +import KeyboardNavigatable from '../common/utils/KeyboardNavigatable'; import slidable from './utils/slidable'; import History from './utils/History'; import DiscussionControls from './utils/DiscussionControls'; @@ -76,6 +76,7 @@ import isSafariMobile from './utils/isSafariMobile'; export default Object.assign(compat, { 'utils/PostControls': PostControls, + // @deprecated import from 'flarum/common/utils/KeyboardNavigatable' instead 'utils/KeyboardNavigatable': KeyboardNavigatable, 'utils/slidable': slidable, 'utils/History': History, diff --git a/framework/core/js/src/forum/components/Search.tsx b/framework/core/js/src/forum/components/Search.tsx index 4d04d51be..07fca3f45 100644 --- a/framework/core/js/src/forum/components/Search.tsx +++ b/framework/core/js/src/forum/components/Search.tsx @@ -4,7 +4,7 @@ import LoadingIndicator from '../../common/components/LoadingIndicator'; import ItemList from '../../common/utils/ItemList'; import classList from '../../common/utils/classList'; import extractText from '../../common/utils/extractText'; -import KeyboardNavigatable from '../utils/KeyboardNavigatable'; +import KeyboardNavigatable from '../../common/utils/KeyboardNavigatable'; import icon from '../../common/helpers/icon'; import SearchState from '../states/SearchState'; import DiscussionsSearchSource from './DiscussionsSearchSource';