mirror of
https://github.com/flarum/framework.git
synced 2025-04-25 14:14:03 +08:00
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:
parent
a129999132
commit
a53a0db2b7
@ -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';
|
||||
|
@ -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';
|
||||
|
6
extensions/tags/js/src/@types/shims.d.ts
vendored
6
extensions/tags/js/src/@types/shims.d.ts
vendored
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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]} />;
|
||||
});
|
||||
});
|
||||
}
|
@ -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();
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
};
|
||||
|
483
extensions/tags/js/src/common/components/TagSelectionModal.tsx
Normal file
483
extensions/tags/js/src/common/components/TagSelectionModal.tsx
Normal 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.');
|
||||
}
|
||||
}
|
@ -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 {
|
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
@import "root";
|
||||
@import "TagLabel";
|
||||
@import "TagIcon";
|
||||
@import "TagSelectionModal";
|
||||
|
@ -1,7 +1,6 @@
|
||||
@import "common/common";
|
||||
|
||||
@import "forum/TagCloud";
|
||||
@import "forum/TagDiscussionModal";
|
||||
@import "forum/TagHero";
|
||||
@import "forum/TagTiles";
|
||||
@import "forum/ToggleButton";
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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.
|
||||
|
@ -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,
|
||||
|
@ -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';
|
||||
|
Loading…
x
Reference in New Issue
Block a user