Merge pull request #69 from paviliondev/realtime-validation
FEATURE: Implemented realtime validation framework
Dieser Commit ist enthalten in:
Commit
7f021791f5
39 geänderte Dateien mit 701 neuen und 13 gelöschten Zeilen
|
@ -4,6 +4,7 @@ import { computed } from "@ember/object";
|
||||||
import { selectKitContent } from '../lib/wizard';
|
import { selectKitContent } from '../lib/wizard';
|
||||||
import UndoChanges from '../mixins/undo-changes';
|
import UndoChanges from '../mixins/undo-changes';
|
||||||
import Component from "@ember/component";
|
import Component from "@ember/component";
|
||||||
|
import wizardSchema from '../lib/wizard-schema';
|
||||||
|
|
||||||
export default Component.extend(UndoChanges, {
|
export default Component.extend(UndoChanges, {
|
||||||
componentType: 'field',
|
componentType: 'field',
|
||||||
|
@ -26,6 +27,19 @@ export default Component.extend(UndoChanges, {
|
||||||
showAdvanced: alias('field.type'),
|
showAdvanced: alias('field.type'),
|
||||||
messageUrl: 'https://thepavilion.io/t/2809',
|
messageUrl: 'https://thepavilion.io/t/2809',
|
||||||
|
|
||||||
|
@discourseComputed('field.type')
|
||||||
|
validations(type) {
|
||||||
|
const applicableToField = [];
|
||||||
|
|
||||||
|
for(let validation in wizardSchema.field.validations) {
|
||||||
|
if ((wizardSchema.field.validations[validation]["types"]).includes(type)) {
|
||||||
|
applicableToField.push(validation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return applicableToField;
|
||||||
|
},
|
||||||
|
|
||||||
@discourseComputed('field.type')
|
@discourseComputed('field.type')
|
||||||
isDateTime(type) {
|
isDateTime(type) {
|
||||||
return ['date_time', 'date', 'time'].indexOf(type) > -1;
|
return ['date_time', 'date', 'time'].indexOf(type) > -1;
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
import Component from "@ember/component";
|
||||||
|
import EmberObject from "@ember/object";
|
||||||
|
import { cloneJSON } from "discourse-common/lib/object";
|
||||||
|
import Category from "discourse/models/category";
|
||||||
|
|
||||||
|
export default Component.extend({
|
||||||
|
classNames: ["realtime-validations"],
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this._super(...arguments);
|
||||||
|
if (!this.validations) return;
|
||||||
|
|
||||||
|
if (!this.field.validations) {
|
||||||
|
const validations = {};
|
||||||
|
|
||||||
|
this.validations.forEach((validation) => {
|
||||||
|
validations[validation] = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.set("field.validations", EmberObject.create(validations));
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationBuffer = cloneJSON(this.get("field.validations"));
|
||||||
|
let bufferCategories;
|
||||||
|
if (
|
||||||
|
validationBuffer.similar_topics &&
|
||||||
|
(bufferCategories = validationBuffer.similar_topics.categories)
|
||||||
|
) {
|
||||||
|
const categories = Category.findByIds(bufferCategories);
|
||||||
|
validationBuffer.similar_topics.categories = categories;
|
||||||
|
}
|
||||||
|
this.set("validationBuffer", validationBuffer);
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
updateValidationCategories(type, validation, categories) {
|
||||||
|
this.set(`validationBuffer.${type}.categories`, categories);
|
||||||
|
this.set(
|
||||||
|
`field.validations.${type}.categories`,
|
||||||
|
categories.map((category) => category.id)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
|
@ -244,6 +244,10 @@ export function buildFieldTypes(types) {
|
||||||
wizardSchema.field.types = types;
|
wizardSchema.field.types = types;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildFieldValidations(validations) {
|
||||||
|
wizardSchema.field.validations = validations;
|
||||||
|
}
|
||||||
|
|
||||||
if (Discourse.SiteSettings.wizard_apis_enabled) {
|
if (Discourse.SiteSettings.wizard_apis_enabled) {
|
||||||
wizardSchema.action.types.send_to_api = {
|
wizardSchema.action.types.send_to_api = {
|
||||||
api: null,
|
api: null,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import DiscourseRoute from "discourse/routes/discourse";
|
import DiscourseRoute from "discourse/routes/discourse";
|
||||||
import { buildFieldTypes } from '../lib/wizard-schema';
|
import { buildFieldTypes, buildFieldValidations } from '../lib/wizard-schema';
|
||||||
import EmberObject, { set } from "@ember/object";
|
import EmberObject, { set } from "@ember/object";
|
||||||
import { A } from "@ember/array";
|
import { A } from "@ember/array";
|
||||||
import { all } from "rsvp";
|
import { all } from "rsvp";
|
||||||
|
@ -12,6 +12,7 @@ export default DiscourseRoute.extend({
|
||||||
|
|
||||||
afterModel(model) {
|
afterModel(model) {
|
||||||
buildFieldTypes(model.field_types);
|
buildFieldTypes(model.field_types);
|
||||||
|
buildFieldValidations(model.realtime_validations);
|
||||||
|
|
||||||
return all([
|
return all([
|
||||||
this._getThemes(model),
|
this._getThemes(model),
|
||||||
|
|
|
@ -219,6 +219,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{#if validations}}
|
||||||
|
{{wizard-realtime-validations field=field validations=validations}}
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
<h3>{{i18n 'admin.wizard.field.validations.header'}}</h3>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{{#each-in field.validations as |type props|}}
|
||||||
|
<li>
|
||||||
|
<span class="setting-title">
|
||||||
|
<h4>{{i18n (concat 'admin.wizard.field.validations.' type)}}</h4>
|
||||||
|
{{input type="checkbox" checked=props.status}}
|
||||||
|
{{i18n 'admin.wizard.field.validations.enabled'}}
|
||||||
|
</span>
|
||||||
|
<div class="validation-container">
|
||||||
|
<div class="validation-section">
|
||||||
|
<div class="setting-label">
|
||||||
|
<label>{{i18n 'admin.wizard.field.validations.categories'}}</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-value">
|
||||||
|
{{category-selector
|
||||||
|
categories=(get this (concat 'validationBuffer.' type '.categories'))
|
||||||
|
onChange=(action 'updateValidationCategories' type props)
|
||||||
|
class="wizard"}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="validation-section">
|
||||||
|
<div class="setting-label">
|
||||||
|
<label>{{i18n 'admin.wizard.field.validations.date_after'}}</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-value">
|
||||||
|
{{date-picker-past
|
||||||
|
value=(readonly props.date_after)
|
||||||
|
containerId="date-container"
|
||||||
|
onSelect=(action (mut props.date_after))}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="validation-section">
|
||||||
|
<div class="setting-label">
|
||||||
|
<label>{{i18n 'admin.wizard.field.validations.position'}}</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-value">
|
||||||
|
{{radio-button name=(concat type field.id) value="above" selection=props.position}}
|
||||||
|
{{i18n 'admin.wizard.field.validations.above'}}
|
||||||
|
{{radio-button name=(concat type field.id) value="below" selection=props.position}}
|
||||||
|
{{i18n 'admin.wizard.field.validations.below'}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{/each-in}}
|
||||||
|
</ul>
|
9
assets/javascripts/wizard/components/field-validators.js.es6
Normale Datei
9
assets/javascripts/wizard/components/field-validators.js.es6
Normale Datei
|
@ -0,0 +1,9 @@
|
||||||
|
import Component from "@ember/component";
|
||||||
|
import { observes } from "discourse-common/utils/decorators";
|
||||||
|
export default Component.extend({
|
||||||
|
actions: {
|
||||||
|
perform() {
|
||||||
|
this.appEvents.trigger("custom-wizard:validate");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
84
assets/javascripts/wizard/components/similar-topics-validator.js.es6
Normale Datei
84
assets/javascripts/wizard/components/similar-topics-validator.js.es6
Normale Datei
|
@ -0,0 +1,84 @@
|
||||||
|
import WizardFieldValidator from "../../wizard/components/validator";
|
||||||
|
import { deepMerge } from "discourse-common/lib/object";
|
||||||
|
import { observes } from "discourse-common/utils/decorators";
|
||||||
|
import { cancel, later } from "@ember/runloop";
|
||||||
|
import { A } from "@ember/array";
|
||||||
|
import EmberObject, { computed } from "@ember/object";
|
||||||
|
import { notEmpty, and, equal, empty } from "@ember/object/computed";
|
||||||
|
|
||||||
|
export default WizardFieldValidator.extend({
|
||||||
|
classNames: ['similar-topics-validator'],
|
||||||
|
similarTopics: null,
|
||||||
|
hasInput: notEmpty('field.value'),
|
||||||
|
hasSimilarTopics: notEmpty('similarTopics'),
|
||||||
|
hasNotSearched: equal('similarTopics', null),
|
||||||
|
noSimilarTopics: computed('similarTopics', function() {
|
||||||
|
return this.similarTopics !== null && this.similarTopics.length == 0;
|
||||||
|
}),
|
||||||
|
showDefault: computed('hasNotSearched', 'hasInput', 'typing', function() {
|
||||||
|
return this.hasInput && (this.hasNotSearched || this.typing);
|
||||||
|
}),
|
||||||
|
showSimilarTopics: computed('typing', 'hasSimilarTopics', function() {
|
||||||
|
return this.hasSimilarTopics && !this.typing;
|
||||||
|
}),
|
||||||
|
showNoSimilarTopics: computed('typing', 'noSimilarTopics', function() {
|
||||||
|
return this.noSimilarTopics && !this.typing;
|
||||||
|
}),
|
||||||
|
|
||||||
|
validate() {},
|
||||||
|
|
||||||
|
@observes("field.value")
|
||||||
|
customValidate() {
|
||||||
|
const field = this.field;
|
||||||
|
|
||||||
|
if (!field.value) return;
|
||||||
|
const value = field.value;
|
||||||
|
|
||||||
|
this.set("typing", true);
|
||||||
|
|
||||||
|
if (value && value.length < 5) {
|
||||||
|
this.set('similarTopics', null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastKeyUp = new Date();
|
||||||
|
this._lastKeyUp = lastKeyUp;
|
||||||
|
|
||||||
|
// One second from now, check to see if the last key was hit when
|
||||||
|
// we recorded it. If it was, the user paused typing.
|
||||||
|
cancel(this._lastKeyTimeout);
|
||||||
|
this._lastKeyTimeout = later(() => {
|
||||||
|
if (lastKeyUp !== this._lastKeyUp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.set("typing", false);
|
||||||
|
|
||||||
|
this.updateSimilarTopics();
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSimilarTopics() {
|
||||||
|
this.set('updating', true);
|
||||||
|
|
||||||
|
this.backendValidate({
|
||||||
|
title: this.get("field.value"),
|
||||||
|
categories: this.get("validation.categories"),
|
||||||
|
date_after: this.get("validation.date_after"),
|
||||||
|
}).then((result) => {
|
||||||
|
const similarTopics = A(
|
||||||
|
deepMerge(result["topics"], result["similar_topics"])
|
||||||
|
);
|
||||||
|
similarTopics.forEach(function (topic, index) {
|
||||||
|
similarTopics[index] = EmberObject.create(topic);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.set("similarTopics", similarTopics);
|
||||||
|
}).finally(() => this.set('updating', false));
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
closeMessage() {
|
||||||
|
this.set("showMessage", false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
44
assets/javascripts/wizard/components/validator.js.es6
Normale Datei
44
assets/javascripts/wizard/components/validator.js.es6
Normale Datei
|
@ -0,0 +1,44 @@
|
||||||
|
import Component from "@ember/component";
|
||||||
|
import { equal } from "@ember/object/computed";
|
||||||
|
import { ajax } from "discourse/lib/ajax";
|
||||||
|
import { getToken } from "wizard/lib/ajax";
|
||||||
|
|
||||||
|
export default Component.extend({
|
||||||
|
classNames: ['validator'],
|
||||||
|
classNameBindings: ["isValid", "isInvalid"],
|
||||||
|
validMessageKey: null,
|
||||||
|
invalidMessageKey: null,
|
||||||
|
isValid: null,
|
||||||
|
isInvalid: equal("isValid", false),
|
||||||
|
layoutName: "components/validator", // useful for sharing the template with extending components
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this._super(...arguments);
|
||||||
|
|
||||||
|
if (this.get("validation.backend")) {
|
||||||
|
// set a function that can be called as often as it need to
|
||||||
|
// from the derived component
|
||||||
|
this.backendValidate = (params) => {
|
||||||
|
return ajax("/realtime-validations", {
|
||||||
|
data: {
|
||||||
|
type: this.get("type"),
|
||||||
|
authenticity_token: getToken(),
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
didInsertElement() {
|
||||||
|
this.appEvents.on("custom-wizard:validate", this, this.checkIsValid);
|
||||||
|
},
|
||||||
|
|
||||||
|
willDestroyElement() {
|
||||||
|
this.appEvents.off("custom-wizard:validate", this, this.checkIsValid);
|
||||||
|
},
|
||||||
|
|
||||||
|
checkIsValid() {
|
||||||
|
this.set("isValid", this.validate());
|
||||||
|
},
|
||||||
|
});
|
36
assets/javascripts/wizard/components/wizard-similar-topics.js.es6
Normale Datei
36
assets/javascripts/wizard/components/wizard-similar-topics.js.es6
Normale Datei
|
@ -0,0 +1,36 @@
|
||||||
|
import Component from "@ember/component";
|
||||||
|
import { bind } from "@ember/runloop";
|
||||||
|
import { observes } from "discourse-common/utils/decorators";
|
||||||
|
|
||||||
|
export default Component.extend({
|
||||||
|
classNames: ['wizard-similar-topics'],
|
||||||
|
showTopics: true,
|
||||||
|
|
||||||
|
didInsertElement() {
|
||||||
|
$(document).on("click", bind(this, this.documentClick));
|
||||||
|
},
|
||||||
|
|
||||||
|
willDestroyElement() {
|
||||||
|
$(document).off("click", bind(this, this.documentClick));
|
||||||
|
},
|
||||||
|
|
||||||
|
documentClick(e) {
|
||||||
|
if (this._state == "destroying") return;
|
||||||
|
let $target = $(e.target);
|
||||||
|
|
||||||
|
if (!$target.hasClass('show-topics')) {
|
||||||
|
this.set('showTopics', false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
@observes('topics')
|
||||||
|
toggleShowWhenTopicsChange() {
|
||||||
|
this.set('showTopics', true);
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
toggleShowTopics() {
|
||||||
|
this.set('showTopics', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
22
assets/javascripts/wizard/helpers/date-node.js.es6
Normale Datei
22
assets/javascripts/wizard/helpers/date-node.js.es6
Normale Datei
|
@ -0,0 +1,22 @@
|
||||||
|
import { registerUnbound } from "discourse-common/lib/helpers";
|
||||||
|
import { longDate, number, relativeAge } from "discourse/lib/formatter";
|
||||||
|
|
||||||
|
export default registerUnbound("date-node", function (dt) {
|
||||||
|
if (typeof dt === "string") {
|
||||||
|
dt = new Date(dt);
|
||||||
|
}
|
||||||
|
if (dt) {
|
||||||
|
const attributes = {
|
||||||
|
title: longDate(dt),
|
||||||
|
"data-time": dt.getTime(),
|
||||||
|
"data-format": "tiny",
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalString = `<span class="relative-date" title="${
|
||||||
|
attributes["title"]
|
||||||
|
}" data-time="${attributes["data-time"]}" data-format="${
|
||||||
|
attributes["data-format"]
|
||||||
|
}">${relativeAge(dt)}</span>`;
|
||||||
|
return new Handlebars.SafeString(finalString);
|
||||||
|
}
|
||||||
|
});
|
13
assets/javascripts/wizard/templates/components/field-validators.hbs
Normale Datei
13
assets/javascripts/wizard/templates/components/field-validators.hbs
Normale Datei
|
@ -0,0 +1,13 @@
|
||||||
|
{{#if field.validations}}
|
||||||
|
{{#each-in field.validations.above as |type validation|}}
|
||||||
|
{{component validation.component field=field type=type validation=validation}}
|
||||||
|
{{/each-in}}
|
||||||
|
|
||||||
|
{{yield (hash perform=(action 'perform'))}}
|
||||||
|
|
||||||
|
{{#each-in field.validations.below as |type validation|}}
|
||||||
|
{{component validation.component field=field type=type validation=validation}}
|
||||||
|
{{/each-in}}
|
||||||
|
{{else}}
|
||||||
|
{{yield}}
|
||||||
|
{{/if}}
|
|
@ -0,0 +1,12 @@
|
||||||
|
{{#if loading}}
|
||||||
|
<label>{{i18n 'realtime_validations.similar_topics.loading'}}</label>
|
||||||
|
{{else if showSimilarTopics}}
|
||||||
|
<label>{{i18n 'realtime_validations.similar_topics.results'}}</label>
|
||||||
|
{{wizard-similar-topics topics=similarTopics}}
|
||||||
|
{{else if showNoSimilarTopics}}
|
||||||
|
<label>{{i18n 'realtime_validations.similar_topics.no_results'}}</label>
|
||||||
|
{{else if showDefault}}
|
||||||
|
<label>{{i18n 'realtime_validations.similar_topics.default'}}</label>
|
||||||
|
{{else}}
|
||||||
|
<label></label>
|
||||||
|
{{/if}}
|
5
assets/javascripts/wizard/templates/components/validator.hbs
Normale Datei
5
assets/javascripts/wizard/templates/components/validator.hbs
Normale Datei
|
@ -0,0 +1,5 @@
|
||||||
|
{{#if isValid}}
|
||||||
|
{{i18n validMessageKey}}
|
||||||
|
{{else}}
|
||||||
|
{{i18n invalidMessageKey}}
|
||||||
|
{{/if}}
|
|
@ -10,11 +10,13 @@
|
||||||
<div class='field-description'>{{cookedDescription}}</div>
|
<div class='field-description'>{{cookedDescription}}</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if inputComponentName}}
|
{{#field-validators field=field as |validators|}}
|
||||||
|
{{#if inputComponentName}}
|
||||||
<div class='input-area'>
|
<div class='input-area'>
|
||||||
{{component inputComponentName field=field step=step fieldClass=fieldClass wizard=wizard}}
|
{{component inputComponentName field=field step=step fieldClass=fieldClass wizard=wizard }}
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
{{/field-validators}}
|
||||||
|
|
||||||
{{#if field.char_counter}}
|
{{#if field.char_counter}}
|
||||||
{{#if textType}}
|
{{#if textType}}
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
<a href="{{topic.url}}" target="_blank">
|
||||||
|
<span class="title">{{html-safe topic.fancy_title}}</span>
|
||||||
|
<div class="blurb">{{date-node topic.created_at}} - {{html-safe topic.blurb}}</div>
|
||||||
|
</a>
|
|
@ -0,0 +1,11 @@
|
||||||
|
{{#if showTopics}}
|
||||||
|
<ul>
|
||||||
|
{{#each topics as |topic|}}
|
||||||
|
<li>{{wizard-similar-topic topic=topic}}</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<a class="show-topics" {{action "toggleShowTopics"}}>
|
||||||
|
{{i18n 'realtime_validations.similar_topics.show'}}
|
||||||
|
</a>
|
||||||
|
{{/if}}
|
|
@ -643,3 +643,31 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.realtime-validations > ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
> li {
|
||||||
|
background-color: var(--primary-low);
|
||||||
|
padding: 1em;
|
||||||
|
margin: 0 0 1em 0;
|
||||||
|
|
||||||
|
input {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-container {
|
||||||
|
display: flex;
|
||||||
|
padding: 1em 0;
|
||||||
|
|
||||||
|
.validation-section {
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard.category-selector {
|
||||||
|
width: 200px !important;
|
||||||
|
}
|
||||||
|
|
|
@ -155,4 +155,8 @@
|
||||||
.select-kit.combo-box.group-dropdown {
|
.select-kit.combo-box.group-dropdown {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-field input {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
}
|
}
|
40
assets/stylesheets/wizard/custom/validators.scss
Normale Datei
40
assets/stylesheets/wizard/custom/validators.scss
Normale Datei
|
@ -0,0 +1,40 @@
|
||||||
|
.similar-topics-validator {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
label {
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-similar-topics {
|
||||||
|
margin-left: 3px;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
background-color: var(--tertiary-low);
|
||||||
|
padding: 5px;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 25px;
|
||||||
|
box-shadow: shadow("dropdown");
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blurb {
|
||||||
|
margin-left: 0.5em;
|
||||||
|
color: var(--primary-high);
|
||||||
|
font-size: $font-down-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-topics {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,6 +10,7 @@
|
||||||
@import "custom/wizard";
|
@import "custom/wizard";
|
||||||
@import "custom/step";
|
@import "custom/step";
|
||||||
@import "custom/field";
|
@import "custom/field";
|
||||||
|
@import "custom/validators";
|
||||||
@import "custom/mobile";
|
@import "custom/mobile";
|
||||||
@import "custom/autocomplete";
|
@import "custom/autocomplete";
|
||||||
@import "custom/composer";
|
@import "custom/composer";
|
||||||
|
|
|
@ -176,6 +176,15 @@ en:
|
||||||
date_time_format:
|
date_time_format:
|
||||||
label: "Format"
|
label: "Format"
|
||||||
instructions: "<a href='https://momentjs.com/docs/#/displaying/format/' target='_blank'>Moment.js format</a>"
|
instructions: "<a href='https://momentjs.com/docs/#/displaying/format/' target='_blank'>Moment.js format</a>"
|
||||||
|
validations:
|
||||||
|
header: "Realtime Validations"
|
||||||
|
enabled: "Enabled"
|
||||||
|
similar_topics: "Similar Topics"
|
||||||
|
position: "Position"
|
||||||
|
above: "Above"
|
||||||
|
below: "Below"
|
||||||
|
categories: "Categories"
|
||||||
|
date_after: "Date After"
|
||||||
|
|
||||||
type:
|
type:
|
||||||
text: "Text"
|
text: "Text"
|
||||||
|
@ -511,3 +520,11 @@ en:
|
||||||
yourself_confirm:
|
yourself_confirm:
|
||||||
title: "Did you forget to add recipients?"
|
title: "Did you forget to add recipients?"
|
||||||
body: "Right now this message is only being sent to yourself!"
|
body: "Right now this message is only being sent to yourself!"
|
||||||
|
|
||||||
|
realtime_validations:
|
||||||
|
similar_topics:
|
||||||
|
default: "When you stop typing we'll look for similar topics."
|
||||||
|
results: "Your topic is similar to..."
|
||||||
|
no_results: "No similar topics."
|
||||||
|
loading: "Looking for similar topics..."
|
||||||
|
show: "show"
|
||||||
|
|
|
@ -9,6 +9,7 @@ end
|
||||||
Discourse::Application.routes.append do
|
Discourse::Application.routes.append do
|
||||||
mount ::CustomWizard::Engine, at: 'w'
|
mount ::CustomWizard::Engine, at: 'w'
|
||||||
post 'wizard/authorization/callback' => "custom_wizard/authorization#callback"
|
post 'wizard/authorization/callback' => "custom_wizard/authorization#callback"
|
||||||
|
get 'realtime-validations' => 'custom_wizard/realtime_validations#validate'
|
||||||
|
|
||||||
scope module: 'custom_wizard', constraints: AdminConstraint.new do
|
scope module: 'custom_wizard', constraints: AdminConstraint.new do
|
||||||
get 'admin/wizards' => 'admin#index'
|
get 'admin/wizards' => 'admin#index'
|
||||||
|
|
|
@ -8,6 +8,7 @@ class CustomWizard::AdminWizardController < CustomWizard::AdminController
|
||||||
each_serializer: CustomWizard::BasicWizardSerializer
|
each_serializer: CustomWizard::BasicWizardSerializer
|
||||||
),
|
),
|
||||||
field_types: CustomWizard::Field.types,
|
field_types: CustomWizard::Field.types,
|
||||||
|
realtime_validations: CustomWizard::RealtimeValidation.types,
|
||||||
custom_fields: custom_field_list
|
custom_fields: custom_field_list
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
@ -104,7 +105,8 @@ class CustomWizard::AdminWizardController < CustomWizard::AdminController
|
||||||
:limit,
|
:limit,
|
||||||
:property,
|
:property,
|
||||||
prefill: mapped_params,
|
prefill: mapped_params,
|
||||||
content: mapped_params
|
content: mapped_params,
|
||||||
|
validations: {},
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
actions: [
|
actions: [
|
||||||
|
|
18
controllers/custom_wizard/realtime_validations.rb
Normale Datei
18
controllers/custom_wizard/realtime_validations.rb
Normale Datei
|
@ -0,0 +1,18 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CustomWizard::RealtimeValidationsController < ::ApplicationController
|
||||||
|
def validate
|
||||||
|
klass_str = "CustomWizard::RealtimeValidation::#{validation_params[:type].camelize}"
|
||||||
|
result = klass_str.constantize.new(current_user).perform(validation_params)
|
||||||
|
render_serialized(result.items, "#{klass_str}Serializer".constantize, result.serializer_opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validation_params
|
||||||
|
params.require(:type)
|
||||||
|
settings = ::CustomWizard::RealtimeValidation.types[params[:type].to_sym]
|
||||||
|
params.require(settings[:required_params]) if settings[:required_params].present?
|
||||||
|
params
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"result": {
|
"result": {
|
||||||
"covered_percent": 89.45
|
"line": 89.57
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ module CustomWizardFieldExtension
|
||||||
:description,
|
:description,
|
||||||
:image,
|
:image,
|
||||||
:key,
|
:key,
|
||||||
|
:validations,
|
||||||
:min_length,
|
:min_length,
|
||||||
:max_length,
|
:max_length,
|
||||||
:char_counter,
|
:char_counter,
|
||||||
|
@ -20,6 +21,7 @@ module CustomWizardFieldExtension
|
||||||
@description = attrs[:description]
|
@description = attrs[:description]
|
||||||
@image = attrs[:image]
|
@image = attrs[:image]
|
||||||
@key = attrs[:key]
|
@key = attrs[:key]
|
||||||
|
@validations = attrs[:validations]
|
||||||
@min_length = attrs[:min_length]
|
@min_length = attrs[:min_length]
|
||||||
@max_length = attrs[:max_length]
|
@max_length = attrs[:max_length]
|
||||||
@char_counter = attrs[:char_counter]
|
@char_counter = attrs[:char_counter]
|
||||||
|
|
|
@ -158,6 +158,7 @@ class CustomWizard::Builder
|
||||||
params[:description] = field_template['description'] if field_template['description']
|
params[:description] = field_template['description'] if field_template['description']
|
||||||
params[:image] = field_template['image'] if field_template['image']
|
params[:image] = field_template['image'] if field_template['image']
|
||||||
params[:key] = field_template['key'] if field_template['key']
|
params[:key] = field_template['key'] if field_template['key']
|
||||||
|
params[:validations] = field_template['validations'] if field_template['validations']
|
||||||
params[:min_length] = field_template['min_length'] if field_template['min_length']
|
params[:min_length] = field_template['min_length'] if field_template['min_length']
|
||||||
params[:max_length] = field_template['max_length'] if field_template['max_length']
|
params[:max_length] = field_template['max_length'] if field_template['max_length']
|
||||||
params[:char_counter] = field_template['char_counter'] if field_template['char_counter']
|
params[:char_counter] = field_template['char_counter'] if field_template['char_counter']
|
||||||
|
|
|
@ -5,7 +5,8 @@ class CustomWizard::Field
|
||||||
min_length: nil,
|
min_length: nil,
|
||||||
max_length: nil,
|
max_length: nil,
|
||||||
prefill: nil,
|
prefill: nil,
|
||||||
char_counter: nil
|
char_counter: nil,
|
||||||
|
validations: nil
|
||||||
},
|
},
|
||||||
textarea: {
|
textarea: {
|
||||||
min_length: nil,
|
min_length: nil,
|
||||||
|
|
14
lib/custom_wizard/realtime_validation.rb
Normale Datei
14
lib/custom_wizard/realtime_validation.rb
Normale Datei
|
@ -0,0 +1,14 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CustomWizard::RealtimeValidation
|
||||||
|
cattr_accessor :types
|
||||||
|
|
||||||
|
@@types ||= {
|
||||||
|
similar_topics: {
|
||||||
|
types: [:text],
|
||||||
|
component: "similar-topics-validator",
|
||||||
|
backend: true,
|
||||||
|
required_params: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
11
lib/custom_wizard/realtime_validations/result.rb
Normale Datei
11
lib/custom_wizard/realtime_validations/result.rb
Normale Datei
|
@ -0,0 +1,11 @@
|
||||||
|
class CustomWizard::RealtimeValidation::Result
|
||||||
|
attr_accessor :type,
|
||||||
|
:items,
|
||||||
|
:serializer_opts
|
||||||
|
|
||||||
|
def initialize(type)
|
||||||
|
@type = type
|
||||||
|
@items = []
|
||||||
|
@serializer_opts = {}
|
||||||
|
end
|
||||||
|
end
|
42
lib/custom_wizard/realtime_validations/similar_topics.rb
Normale Datei
42
lib/custom_wizard/realtime_validations/similar_topics.rb
Normale Datei
|
@ -0,0 +1,42 @@
|
||||||
|
class CustomWizard::RealtimeValidation::SimilarTopics
|
||||||
|
attr_accessor :user
|
||||||
|
|
||||||
|
def initialize(user)
|
||||||
|
@user = user
|
||||||
|
end
|
||||||
|
|
||||||
|
class SimilarTopic
|
||||||
|
def initialize(topic)
|
||||||
|
@topic = topic
|
||||||
|
end
|
||||||
|
|
||||||
|
attr_reader :topic
|
||||||
|
|
||||||
|
def blurb
|
||||||
|
Search::GroupedSearchResults.blurb_for(cooked: @topic.try(:blurb))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform(params)
|
||||||
|
title = params[:title]
|
||||||
|
raw = params[:raw]
|
||||||
|
categories = params[:categories]
|
||||||
|
date_after = params[:date_after]
|
||||||
|
|
||||||
|
result = CustomWizard::RealtimeValidation::Result.new(:similar_topic)
|
||||||
|
|
||||||
|
if title.length < SiteSetting.min_title_similar_length || !Topic.count_exceeds_minimum?
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
topics = Topic.similar_to(title, raw, user).to_a
|
||||||
|
topics.select! { |t| categories.include?(t.category.id.to_s) } if categories.present?
|
||||||
|
topics.select! { |t| t.created_at > DateTime.parse(date_after) } if date_after.present?
|
||||||
|
topics.map! { |t| SimilarTopic.new(t) }
|
||||||
|
|
||||||
|
result.items = topics
|
||||||
|
result.serializer_opts = { root: :similar_topics }
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
|
@ -47,6 +47,7 @@ after_initialize do
|
||||||
../controllers/custom_wizard/admin/custom_fields.rb
|
../controllers/custom_wizard/admin/custom_fields.rb
|
||||||
../controllers/custom_wizard/wizard.rb
|
../controllers/custom_wizard/wizard.rb
|
||||||
../controllers/custom_wizard/steps.rb
|
../controllers/custom_wizard/steps.rb
|
||||||
|
../controllers/custom_wizard/realtime_validations.rb
|
||||||
../jobs/clear_after_time_wizard.rb
|
../jobs/clear_after_time_wizard.rb
|
||||||
../jobs/refresh_api_access_token.rb
|
../jobs/refresh_api_access_token.rb
|
||||||
../jobs/set_after_time_wizard.rb
|
../jobs/set_after_time_wizard.rb
|
||||||
|
@ -58,6 +59,9 @@ after_initialize do
|
||||||
../lib/custom_wizard/cache.rb
|
../lib/custom_wizard/cache.rb
|
||||||
../lib/custom_wizard/custom_field.rb
|
../lib/custom_wizard/custom_field.rb
|
||||||
../lib/custom_wizard/field.rb
|
../lib/custom_wizard/field.rb
|
||||||
|
../lib/custom_wizard/realtime_validation.rb
|
||||||
|
../lib/custom_wizard/realtime_validations/result.rb
|
||||||
|
../lib/custom_wizard/realtime_validations/similar_topics.rb
|
||||||
../lib/custom_wizard/mapper.rb
|
../lib/custom_wizard/mapper.rb
|
||||||
../lib/custom_wizard/log.rb
|
../lib/custom_wizard/log.rb
|
||||||
../lib/custom_wizard/step_updater.rb
|
../lib/custom_wizard/step_updater.rb
|
||||||
|
@ -79,6 +83,7 @@ after_initialize do
|
||||||
../serializers/custom_wizard/wizard_step_serializer.rb
|
../serializers/custom_wizard/wizard_step_serializer.rb
|
||||||
../serializers/custom_wizard/wizard_serializer.rb
|
../serializers/custom_wizard/wizard_serializer.rb
|
||||||
../serializers/custom_wizard/log_serializer.rb
|
../serializers/custom_wizard/log_serializer.rb
|
||||||
|
../serializers/custom_wizard/realtime_validation/similar_topics_serializer.rb
|
||||||
../extensions/extra_locales_controller.rb
|
../extensions/extra_locales_controller.rb
|
||||||
../extensions/invites_controller.rb
|
../extensions/invites_controller.rb
|
||||||
../extensions/users_controller.rb
|
../extensions/users_controller.rb
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
class ::CustomWizard::RealtimeValidation::SimilarTopicsSerializer < ::SimilarTopicSerializer
|
||||||
|
end
|
|
@ -8,6 +8,7 @@ class CustomWizard::FieldSerializer < ::WizardFieldSerializer
|
||||||
:limit,
|
:limit,
|
||||||
:property,
|
:property,
|
||||||
:content,
|
:content,
|
||||||
|
:validations,
|
||||||
:max_length,
|
:max_length,
|
||||||
:char_counter,
|
:char_counter,
|
||||||
:number
|
:number
|
||||||
|
@ -58,6 +59,17 @@ class CustomWizard::FieldSerializer < ::WizardFieldSerializer
|
||||||
object.choices.present?
|
object.choices.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validations
|
||||||
|
validations = {}
|
||||||
|
object.validations&.each do |type, props|
|
||||||
|
next unless props["status"]
|
||||||
|
validations[props["position"]] ||= {}
|
||||||
|
validations[props["position"]][type] = props.merge CustomWizard::RealtimeValidation.types[type.to_sym]
|
||||||
|
end
|
||||||
|
|
||||||
|
validations
|
||||||
|
end
|
||||||
|
|
||||||
def max_length
|
def max_length
|
||||||
object.max_length
|
object.max_length
|
||||||
end
|
end
|
||||||
|
|
19
spec/components/custom_wizard/realtime_validation_spec.rb
Normale Datei
19
spec/components/custom_wizard/realtime_validation_spec.rb
Normale Datei
|
@ -0,0 +1,19 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require_relative '../../plugin_helper'
|
||||||
|
|
||||||
|
describe CustomWizard::RealtimeValidation do
|
||||||
|
validation_names = CustomWizard::RealtimeValidation.types.keys
|
||||||
|
|
||||||
|
validation_names.each do |name|
|
||||||
|
klass_str = "CustomWizard::RealtimeValidation::#{name.to_s.camelize}"
|
||||||
|
|
||||||
|
it "ensure class for validation: #{name} exists" do
|
||||||
|
expect(klass_str.safe_constantize).not_to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "#{klass_str} has a perform() method" do
|
||||||
|
expect(klass_str.safe_constantize.instance_methods).to include(:perform)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,41 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require_relative '../../../plugin_helper'
|
||||||
|
|
||||||
|
describe ::CustomWizard::RealtimeValidation::SimilarTopics do
|
||||||
|
let(:post) { create_post(title: "matching similar topic") }
|
||||||
|
let(:topic) { post.topic }
|
||||||
|
let(:user) { post.user }
|
||||||
|
|
||||||
|
let(:category) { Fabricate(:category) }
|
||||||
|
let(:cat_post) { create_post(title: "matching similar topic slightly different", category: category) }
|
||||||
|
let(:cat_topic) { cat_post.topic }
|
||||||
|
let(:user) { cat_post.user }
|
||||||
|
|
||||||
|
before do
|
||||||
|
SiteSetting.min_title_similar_length = 5
|
||||||
|
Topic.stubs(:count_exceeds_minimum?).returns(true)
|
||||||
|
SearchIndexer.enable
|
||||||
|
end
|
||||||
|
|
||||||
|
it "fetches similar topics" do
|
||||||
|
validation = ::CustomWizard::RealtimeValidation::SimilarTopics.new(user)
|
||||||
|
result = validation.perform({ title: topic.title.chars.take(10).join })
|
||||||
|
expect(result.items.length).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "filters topics based on category" do
|
||||||
|
validation = ::CustomWizard::RealtimeValidation::SimilarTopics.new(user)
|
||||||
|
result = validation.perform({ title: "matching similar", categories: [category.id.to_s] })
|
||||||
|
expect(result.items.length).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "filters topics based on created date" do
|
||||||
|
topic.update!(created_at: 1.day.ago)
|
||||||
|
cat_topic.update!(created_at: 2.days.ago)
|
||||||
|
|
||||||
|
validation = ::CustomWizard::RealtimeValidation::SimilarTopics.new(user)
|
||||||
|
result = validation.perform({ title: "matching similar", date_after: 1.day.ago.to_date.to_s })
|
||||||
|
expect(result.items.length).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
71
spec/requests/custom_wizard/realtime_validations_spec.rb
Normale Datei
71
spec/requests/custom_wizard/realtime_validations_spec.rb
Normale Datei
|
@ -0,0 +1,71 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require_relative '../../plugin_helper'
|
||||||
|
|
||||||
|
describe CustomWizard::RealtimeValidationsController do
|
||||||
|
|
||||||
|
fab!(:validation_type) { "test_stub" }
|
||||||
|
fab!(:validation_type_stub) {
|
||||||
|
{
|
||||||
|
types: [:text],
|
||||||
|
component: "similar-topics-validator",
|
||||||
|
backend: true,
|
||||||
|
required_params: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
before(:all) do
|
||||||
|
sign_in(Fabricate(:user))
|
||||||
|
CustomWizard::RealtimeValidation.types = { test_stub: validation_type_stub }
|
||||||
|
|
||||||
|
class CustomWizard::RealtimeValidation::TestStub
|
||||||
|
attr_accessor :user
|
||||||
|
|
||||||
|
def initialize(user)
|
||||||
|
@user = user
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform(params)
|
||||||
|
result = CustomWizard::RealtimeValidation::Result.new(:test_stub)
|
||||||
|
result.items = ["hello", "world"]
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class ::CustomWizard::RealtimeValidation::TestStubSerializer < ApplicationSerializer
|
||||||
|
attributes :item
|
||||||
|
|
||||||
|
def item
|
||||||
|
object
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "gives the correct response for a given type" do
|
||||||
|
get '/realtime-validations.json', params: { type: validation_type }
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expected_response = [
|
||||||
|
{ "item" => "hello" },
|
||||||
|
{ "item" => "world" }
|
||||||
|
]
|
||||||
|
expect(JSON.parse(response.body)).to eq(expected_response)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "gives 400 error when no type is passed" do
|
||||||
|
get '/realtime-validations.json'
|
||||||
|
expect(response.status).to eq(400)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "gives 400 error when a required additional param is missing" do
|
||||||
|
CustomWizard::RealtimeValidation.types[:test_stub][:required_params] = [:test1]
|
||||||
|
get '/realtime-validations.json', params: { type: validation_type }
|
||||||
|
expect(response.status).to eq(400)
|
||||||
|
# the addition is only relevant to this test, so getting rid of it
|
||||||
|
CustomWizard::RealtimeValidation.types[:test_stub][:required_params] = []
|
||||||
|
end
|
||||||
|
|
||||||
|
it "gives 500 response code when a non existant type is passed" do
|
||||||
|
get '/realtime-validations.json', params: { type: "random_type" }
|
||||||
|
expect(response.status).to eq(500)
|
||||||
|
end
|
||||||
|
end
|
|
@ -33,6 +33,6 @@ describe CustomWizard::FieldSerializer do
|
||||||
).as_json
|
).as_json
|
||||||
expect(json_array[0][:format]).to eq("YYYY-MM-DD")
|
expect(json_array[0][:format]).to eq("YYYY-MM-DD")
|
||||||
expect(json_array[5][:file_types]).to eq(".jpg,.png")
|
expect(json_array[5][:file_types]).to eq(".jpg,.png")
|
||||||
expect(json_array[4][:number]).to eq("5")
|
expect(json_array[4][:number]).to eq(5)
|
||||||
end
|
end
|
||||||
end
|
end
|
Laden …
In neuem Issue referenzieren