1
0
mirror of https://github.com/flarum/core.git synced 2025-08-05 07:57:46 +02:00

Slug Driver Support (#2456)

- Support slug drivers for core's sluggable models, easily extends to other models
- Add automated testing for affected single-model API routes
- Fix nickname selection UI
- Serialize slugs as `slug` attribute
- Make min search length a constant
This commit is contained in:
Matt Kilgore
2020-12-07 13:33:42 -05:00
committed by GitHub
parent 0df35e8796
commit 0fcbca8f4a
27 changed files with 671 additions and 58 deletions

View File

@@ -25,10 +25,6 @@ export default class BasicsPage extends Page {
'welcome_message',
'display_name_driver',
];
this.values = {};
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
this.localeOptions = {};
const locales = app.data.locales;
@@ -42,8 +38,29 @@ export default class BasicsPage extends Page {
this.displayNameOptions[identifier] = identifier;
}, this);
this.slugDriverOptions = {};
Object.keys(app.data.slugDrivers).forEach((model) => {
this.fields.push(`slug_driver_${model}`);
this.slugDriverOptions[model] = {};
app.data.slugDrivers[model].forEach((option) => {
this.slugDriverOptions[model][option] = option;
});
});
this.values = {};
const settings = app.data.settings;
this.fields.forEach((key) => (this.values[key] = Stream(settings[key])));
if (!this.values.display_name_driver() && displayNameDrivers.includes('username')) this.values.display_name_driver('username');
Object.keys(app.data.slugDrivers).forEach((model) => {
if (!this.values[`slug_driver_${model}`]() && 'default' in this.slugDriverOptions[model]) {
this.values[`slug_driver_${model}`]('default');
}
});
if (typeof this.values.show_language_selector() !== 'number') this.values.show_language_selector(1);
}
@@ -132,20 +149,30 @@ export default class BasicsPage extends Page {
]
)}
{Object.keys(this.displayNameOptions).length > 1
? FieldSet.component(
{
label: app.translator.trans('core.admin.basics.display_name_heading'),
},
[
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>,
Select.component({
options: this.displayNameOptions,
bidi: this.values.display_name_driver,
}),
]
)
: ''}
{Object.keys(this.displayNameOptions).length > 1 ? (
<FieldSet label={app.translator.trans('core.admin.basics.display_name_heading')}>
<div className="helpText">{app.translator.trans('core.admin.basics.display_name_text')}</div>
<Select
options={this.displayNameOptions}
value={this.values.display_name_driver()}
onchange={this.values.display_name_driver}
></Select>
</FieldSet>
) : (
''
)}
{Object.keys(this.slugDriverOptions).map((model) => {
const options = this.slugDriverOptions[model];
if (Object.keys(options).length > 1) {
return (
<FieldSet label={app.translator.trans('core.admin.basics.slug_driver_heading', { model })}>
<div className="helpText">{app.translator.trans('core.admin.basics.slug_driver_text', { model })}</div>
<Select options={options} value={this.values[`slug_driver_${model}`]()} onchange={this.values[`slug_driver_${model}`]}></Select>
</FieldSet>
);
}
})}
{Button.component(
{

View File

@@ -10,6 +10,7 @@ export default class User extends Model {}
Object.assign(User.prototype, {
username: Model.attribute('username'),
slug: Model.attribute('slug'),
displayName: Model.attribute('displayName'),
email: Model.attribute('email'),
isEmailConfirmed: Model.attribute('isEmailConfirmed'),

View File

@@ -14,6 +14,7 @@ import DiscussionControls from '../utils/DiscussionControls';
import slidable from '../utils/slidable';
import extractText from '../../common/utils/extractText';
import classList from '../../common/utils/classList';
import DiscussionPage from './DiscussionPage';
import { escapeRegExp } from 'lodash-es';
/**
@@ -156,9 +157,7 @@ export default class DiscussionListItem extends Component {
* @return {Boolean}
*/
active() {
const idParam = m.route.param('id');
return idParam && idParam.split('-')[0] === this.attrs.discussion.id();
return app.current.matches(DiscussionPage, { discussion: this.attrs.discussion });
}
/**

View File

@@ -109,7 +109,7 @@ export default class DiscussionPage extends Page {
} else {
const params = this.requestParams();
app.store.find('discussions', m.route.param('id').split('-')[0], params).then(this.show.bind(this));
app.store.find('discussions', m.route.param('id'), params).then(this.show.bind(this));
}
m.redraw();
@@ -123,6 +123,7 @@ export default class DiscussionPage extends Page {
*/
requestParams() {
return {
bySlug: true,
page: { near: this.near },
};
}

View File

@@ -21,6 +21,8 @@ import UsersSearchSource from './UsersSearchSource';
* - state: SearchState instance.
*/
export default class Search extends Component {
static MIN_SEARCH_LEN = 3;
oninit(vnode) {
super.oninit(vnode);
this.state = this.attrs.state;
@@ -152,7 +154,7 @@ export default class Search extends Component {
search.searchTimeout = setTimeout(() => {
if (state.isCached(query)) return;
if (query.length >= 3) {
if (query.length >= Search.MIN_SEARCH_LEN) {
search.sources.map((source) => {
if (!source.search) return;

View File

@@ -102,7 +102,7 @@ export default class UserPage extends Page {
});
if (!this.user) {
app.store.find('users', username).then(this.show.bind(this));
app.store.find('users', username, { bySlug: true }).then(this.show.bind(this));
}
}

View File

@@ -1,15 +1,6 @@
import DefaultResolver from '../../common/resolvers/DefaultResolver';
import DiscussionPage from '../components/DiscussionPage';
/**
* This isn't exported as it is a temporary measure.
* A more robust system will be implemented alongside UTF-8 support in beta 15.
*/
function getDiscussionIdFromSlug(slug: string | undefined) {
if (!slug) return;
return slug.split('-')[0];
}
/**
* A custom route resolver for DiscussionPage that generates the same key to all posts
* on the same discussion. It triggers a scroll when going from one post to another
@@ -18,17 +9,32 @@ function getDiscussionIdFromSlug(slug: string | undefined) {
export default class DiscussionPageResolver extends DefaultResolver {
static scrollToPostNumber: string | null = null;
/**
* Remove optional parts of a discussion's slug to keep the substring
* that bijectively maps to a discussion object. By default this just
* extracts the numerical ID from the slug. If a custom discussion
* slugging driver is used, this may need to be overriden.
* @param slug
*/
canonicalizeDiscussionSlug(slug: string | undefined) {
if (!slug) return;
return slug.split('-')[0];
}
/**
* @inheritdoc
*/
makeKey() {
const params = { ...m.route.param() };
if ('near' in params) {
delete params.near;
}
params.id = getDiscussionIdFromSlug(params.id);
params.id = this.canonicalizeDiscussionSlug(params.id);
return this.routeName.replace('.near', '') + JSON.stringify(params);
}
onmatch(args, requestedPath, route) {
if (app.current.matches(DiscussionPage) && getDiscussionIdFromSlug(args.id) === getDiscussionIdFromSlug(m.route.param('id'))) {
if (app.current.matches(DiscussionPage) && this.canonicalizeDiscussionSlug(args.id) === this.canonicalizeDiscussionSlug(m.route.param('id'))) {
// By default, the first post number of any discussion is 1
DiscussionPageResolver.scrollToPostNumber = args.near || '1';
}

View File

@@ -34,9 +34,8 @@ export default function (app) {
* @return {String}
*/
app.route.discussion = (discussion, near) => {
const slug = discussion.slug();
return app.route(near && near !== 1 ? 'discussion.near' : 'discussion', {
id: discussion.id() + (slug.trim() ? '-' + slug : ''),
id: discussion.slug(),
near: near && near !== 1 ? near : undefined,
});
};
@@ -59,7 +58,7 @@ export default function (app) {
*/
app.route.user = (user) => {
return app.route('user', {
username: user.username(),
username: user.slug(),
});
};
}