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
This commit is contained in:
Sami Mazouz 2022-12-12 10:44:33 +01:00 committed by GitHub
parent a129999132
commit a53a0db2b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 652 additions and 364 deletions

View File

@ -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';

View File

@ -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';

View File

@ -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;
}
}

View File

@ -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 <SelectTagsSettingComponent {...attrs} settingValue={this.settings[attrs.setting]} />;
});
});
}

View File

@ -17,7 +17,7 @@ export default function () {
});
extend(PermissionGrid.prototype, 'oncreate', function () {
app.store.find<Tag[]>('tags', {}).then(() => {
app.tagList.load().then(() => {
this.loading = false;
m.redraw();

View File

@ -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<string>;
}
export default class SelectTagsSettingComponent<
CustomAttrs extends SelectTagsSettingComponentAttrs = SelectTagsSettingComponentAttrs
> extends Component<CustomAttrs> {
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 (
<div className="Form-group SelectTagsSettingComponent">
<label>{this.attrs.label}</label>
{this.attrs.help && <p className="helpText">{this.attrs.help}</p>}
{!this.loaded ? (
<LoadingIndicator size="small" display="inline" />
) : (
<Button
className="Button Button--text"
onclick={() =>
app.modal.show(TagSelectionModal, {
selectedTags: this.tags,
onsubmit: (tags: Tag[]) => {
this.tags = tags;
this.attrs.settingValue(JSON.stringify(tags.map((tag) => tag.id())));
},
...this.attrs.options,
})
}
>
{!!this.tags.length ? (
tagsLabel(this.tags)
) : (
<span className="TagLabel untagged">{app.translator.trans('flarum-tags.admin.settings.button_text')}</span>
)}
</Button>
)}
</div>
);
}
}

View File

@ -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();

View File

@ -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);

View File

@ -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,
};

View File

@ -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<CustomAttrs, CustomState> {
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<CustomAttrs, this>) {
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 <LoadingIndicator />;
}
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 [
<div className="Modal-body">
<div className="TagSelectionModal-form">
<div className="TagSelectionModal-form-input">
<div className={'TagsInput FormControl ' + (this.focused ? 'focus' : '')} onclick={() => this.$('.TagsInput input').focus()}>
<span className="TagsInput-selected">
{this.selected.map((tag) => (
<span
className="TagsInput-tag"
onclick={() => {
this.removeTag(tag);
this.onready();
}}
>
{tagLabel(tag)}
</span>
))}
</span>
<input
className="FormControl"
placeholder={extractText(this.getInstruction(primaryCount, secondaryCount))}
bidi={this.filter}
style={{ width: inputWidth + 'ch' }}
onkeydown={this.navigator.navigate.bind(this.navigator)}
onfocus={() => (this.focused = true)}
onblur={() => (this.focused = false)}
/>
</div>
</div>
<div className="TagSelectionModal-form-submit App-primaryControl">
<Button
type="submit"
className="Button Button--primary"
disabled={!this.meetsRequirements(primaryCount, secondaryCount)}
icon="fas fa-check"
>
{app.translator.trans('flarum-tags.lib.tag_selection_modal.submit_button')}
</Button>
</div>
</div>
</div>,
<div className="Modal-footer">
<ul className="TagSelectionModal-list SelectTagList">
{tags.map((tag) => (
<li
data-index={tag.id()}
className={classList({
pinned: tag.position() !== null,
child: !!tag.parent(),
colored: !!tag.color(),
selected: this.selected.includes(tag),
active: this.indexTag === tag,
})}
style={{ color: tag.color() }}
onmouseover={() => (this.indexTag = tag)}
onclick={this.toggleTag.bind(this, tag)}
>
{tagIcon(tag)}
<span className="SelectTagListItem-name">{highlight(tag.name(), filter)}</span>
{tag.description() ? <span className="SelectTagListItem-description">{tag.description()}</span> : ''}
</li>
))}
</ul>
{this.attrs.limits!.allowBypassing && (
<div className="TagSelectionModal-controls">
<ToggleButton className="Button" onclick={() => (this.bypassReqs = !this.bypassReqs)} isToggled={this.bypassReqs}>
{app.translator.trans('flarum-tags.lib.tag_selection_modal.bypass_requirements')}
</ToggleButton>
</div>
)}
</div>,
];
}
/**
* 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.');
}
}

View File

@ -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 {

View File

@ -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<TagDiscussionModalAttrs> {
tagsLoading = true;
export default class TagDiscussionModal extends TagSelectionModal<TagDiscussionModalAttrs> {
static initAttrs(attrs: TagDiscussionModalAttrs) {
super.initAttrs(attrs);
selected: Tag[] = [];
filter = Stream('');
focused = false;
minPrimary = app.forum.attribute<number>('minPrimaryTags');
maxPrimary = app.forum.attribute<number>('maxPrimaryTags');
minSecondary = app.forum.attribute<number>('minSecondaryTags');
maxSecondary = app.forum.attribute<number>('maxSecondaryTags');
bypassReqs = false;
navigator = new KeyboardNavigatable();
tags?: Tag[];
selectedTag?: Tag;
oninit(vnode: Mithril.Vnode<TagDiscussionModalAttrs, this>) {
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: <em>{this.attrs.discussion.title()}</em> })
const title = attrs.discussion
? app.translator.trans('flarum-tags.forum.choose_tags.edit_title', { title: <em>{attrs.discussion.title()}</em> })
: 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<number>('minPrimaryTags'),
secondary: app.forum.attribute<number>('maxSecondaryTags'),
},
min: {
primary: app.forum.attribute<number>('maxPrimaryTags'),
secondary: app.forum.attribute<number>('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 <LoadingIndicator />;
}
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 [
<div className="Modal-body">
<div className="TagDiscussionModal-form">
<div className="TagDiscussionModal-form-input">
<div className={'TagsInput FormControl ' + (this.focused ? 'focus' : '')} onclick={() => this.$('.TagsInput input').focus()}>
<span className="TagsInput-selected">
{this.selected.map((tag) => (
<span
className="TagsInput-tag"
onclick={() => {
this.removeTag(tag);
this.onready();
}}
>
{tagLabel(tag)}
</span>
))}
</span>
<input
className="FormControl"
placeholder={extractText(this.getInstruction(primaryCount, secondaryCount))}
bidi={this.filter}
style={{ width: inputWidth + 'ch' }}
onkeydown={this.navigator.navigate.bind(this.navigator)}
onfocus={() => (this.focused = true)}
onblur={() => (this.focused = false)}
/>
</div>
</div>
<div className="TagDiscussionModal-form-submit App-primaryControl">
<Button
type="submit"
className="Button Button--primary"
disabled={!this.meetsRequirements(primaryCount, secondaryCount)}
icon="fas fa-check"
>
{app.translator.trans('flarum-tags.forum.choose_tags.submit_button')}
</Button>
</div>
</div>
</div>,
<div className="Modal-footer">
<ul className="TagDiscussionModal-list SelectTagList">
{tags
.filter((tag) => filter || !tag.parent() || this.selected.includes(tag.parent() as Tag))
.map((tag) => (
<li
data-index={tag.id()}
className={classList({
pinned: tag.position() !== null,
child: !!tag.parent(),
colored: !!tag.color(),
selected: this.selected.includes(tag),
active: this.selectedTag === tag,
})}
style={{ color: tag.color() }}
onmouseover={() => (this.selectedTag = tag)}
onclick={this.toggleTag.bind(this, tag)}
>
{tagIcon(tag)}
<span className="SelectTagListItem-name">{highlight(tag.name(), filter)}</span>
{tag.description() ? <span className="SelectTagListItem-description">{tag.description()}</span> : ''}
</li>
))}
</ul>
{!!app.forum.attribute('canBypassTagCounts') && (
<div className="TagDiscussionModal-controls">
<ToggleButton className="Button" onclick={() => (this.bypassReqs = !this.bypassReqs)} isToggled={this.bypassReqs}>
{app.translator.trans('flarum-tags.forum.choose_tags.bypass_requirements')}
</ToggleButton>
</div>
)}
</div>,
];
}
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);
};
}
}

View File

@ -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';

View File

@ -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;
}

View File

@ -1,3 +1,4 @@
@import "root";
@import "TagLabel";
@import "TagIcon";
@import "TagSelectionModal";

View File

@ -1,7 +1,6 @@
@import "common/common";
@import "forum/TagCloud";
@import "forum/TagDiscussionModal";
@import "forum/TagHero";
@import "forum/TagTiles";
@import "forum/ToggleButton";

View File

@ -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

View File

@ -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,

View File

@ -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<ModalAttrs extends IInternalModalAttrs = IInternalModalAttrs> extends Component<ModalAttrs> {
export default abstract class Modal<ModalAttrs extends IInternalModalAttrs = IInternalModalAttrs, CustomState = undefined> 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.

View File

@ -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,

View File

@ -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';