diff --git a/assets/javascripts/discourse/components/wizard-custom-field.js.es6 b/assets/javascripts/discourse/components/wizard-custom-field.js.es6
index 97f003df..9f9470ac 100644
--- a/assets/javascripts/discourse/components/wizard-custom-field.js.es6
+++ b/assets/javascripts/discourse/components/wizard-custom-field.js.es6
@@ -4,6 +4,7 @@ import { computed } from "@ember/object";
import { selectKitContent } from '../lib/wizard';
import UndoChanges from '../mixins/undo-changes';
import Component from "@ember/component";
+import wizardSchema from '../lib/wizard-schema';
export default Component.extend(UndoChanges, {
componentType: 'field',
@@ -26,6 +27,19 @@ export default Component.extend(UndoChanges, {
showAdvanced: alias('field.type'),
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')
isDateTime(type) {
return ['date_time', 'date', 'time'].indexOf(type) > -1;
diff --git a/assets/javascripts/discourse/components/wizard-realtime-validations.js.es6 b/assets/javascripts/discourse/components/wizard-realtime-validations.js.es6
new file mode 100644
index 00000000..5bafaac3
--- /dev/null
+++ b/assets/javascripts/discourse/components/wizard-realtime-validations.js.es6
@@ -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)
+ );
+ },
+ },
+});
diff --git a/assets/javascripts/discourse/lib/wizard-schema.js.es6 b/assets/javascripts/discourse/lib/wizard-schema.js.es6
index 7d34a79d..36e1bb70 100644
--- a/assets/javascripts/discourse/lib/wizard-schema.js.es6
+++ b/assets/javascripts/discourse/lib/wizard-schema.js.es6
@@ -244,6 +244,10 @@ export function buildFieldTypes(types) {
wizardSchema.field.types = types;
}
+export function buildFieldValidations(validations) {
+ wizardSchema.field.validations = validations;
+}
+
if (Discourse.SiteSettings.wizard_apis_enabled) {
wizardSchema.action.types.send_to_api = {
api: null,
diff --git a/assets/javascripts/discourse/routes/admin-wizards-wizard.js.es6 b/assets/javascripts/discourse/routes/admin-wizards-wizard.js.es6
index 588936f8..e92e9d60 100644
--- a/assets/javascripts/discourse/routes/admin-wizards-wizard.js.es6
+++ b/assets/javascripts/discourse/routes/admin-wizards-wizard.js.es6
@@ -1,5 +1,5 @@
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 { A } from "@ember/array";
import { all } from "rsvp";
@@ -12,7 +12,8 @@ export default DiscourseRoute.extend({
afterModel(model) {
buildFieldTypes(model.field_types);
-
+ buildFieldValidations(model.realtime_validations);
+
return all([
this._getThemes(model),
this._getApis(model),
diff --git a/assets/javascripts/discourse/templates/components/wizard-custom-field.hbs b/assets/javascripts/discourse/templates/components/wizard-custom-field.hbs
index 563ab716..62c5ea1e 100644
--- a/assets/javascripts/discourse/templates/components/wizard-custom-field.hbs
+++ b/assets/javascripts/discourse/templates/components/wizard-custom-field.hbs
@@ -219,6 +219,9 @@
+ {{#if validations}}
+ {{wizard-realtime-validations field=field validations=validations}}
+ {{/if}}
{{/if}}
{{/if}}
diff --git a/assets/javascripts/discourse/templates/components/wizard-realtime-validations.hbs b/assets/javascripts/discourse/templates/components/wizard-realtime-validations.hbs
new file mode 100644
index 00000000..fe053bc3
--- /dev/null
+++ b/assets/javascripts/discourse/templates/components/wizard-realtime-validations.hbs
@@ -0,0 +1,48 @@
+
{{i18n 'admin.wizard.field.validations.header'}}
+
+
+ {{#each-in field.validations as |type props|}}
+ -
+
+
{{i18n (concat 'admin.wizard.field.validations.' type)}}
+ {{input type="checkbox" checked=props.status}}
+ {{i18n 'admin.wizard.field.validations.enabled'}}
+
+
+
+
+
+
+
+ {{category-selector
+ categories=(get this (concat 'validationBuffer.' type '.categories'))
+ onChange=(action 'updateValidationCategories' type props)
+ class="wizard"}}
+
+
+
+
+
+
+
+ {{date-picker-past
+ value=(readonly props.date_after)
+ containerId="date-container"
+ onSelect=(action (mut props.date_after))}}
+
+
+
+
+
+
+
+ {{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'}}
+
+
+
+
+ {{/each-in}}
+
diff --git a/assets/javascripts/wizard/components/field-validators.js.es6 b/assets/javascripts/wizard/components/field-validators.js.es6
new file mode 100644
index 00000000..85811076
--- /dev/null
+++ b/assets/javascripts/wizard/components/field-validators.js.es6
@@ -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");
+ },
+ },
+});
diff --git a/assets/javascripts/wizard/components/similar-topics-validator.js.es6 b/assets/javascripts/wizard/components/similar-topics-validator.js.es6
new file mode 100644
index 00000000..aa490169
--- /dev/null
+++ b/assets/javascripts/wizard/components/similar-topics-validator.js.es6
@@ -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);
+ },
+ },
+});
diff --git a/assets/javascripts/wizard/components/validator.js.es6 b/assets/javascripts/wizard/components/validator.js.es6
new file mode 100644
index 00000000..3bd13df4
--- /dev/null
+++ b/assets/javascripts/wizard/components/validator.js.es6
@@ -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());
+ },
+});
diff --git a/assets/javascripts/wizard/components/wizard-similar-topics.js.es6 b/assets/javascripts/wizard/components/wizard-similar-topics.js.es6
new file mode 100644
index 00000000..8bdc1416
--- /dev/null
+++ b/assets/javascripts/wizard/components/wizard-similar-topics.js.es6
@@ -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);
+ }
+ }
+})
\ No newline at end of file
diff --git a/assets/javascripts/wizard/helpers/date-node.js.es6 b/assets/javascripts/wizard/helpers/date-node.js.es6
new file mode 100644
index 00000000..99fa01f3
--- /dev/null
+++ b/assets/javascripts/wizard/helpers/date-node.js.es6
@@ -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 = `${relativeAge(dt)}`;
+ return new Handlebars.SafeString(finalString);
+ }
+});
diff --git a/assets/javascripts/wizard/templates/components/field-validators.hbs b/assets/javascripts/wizard/templates/components/field-validators.hbs
new file mode 100644
index 00000000..d765f7ae
--- /dev/null
+++ b/assets/javascripts/wizard/templates/components/field-validators.hbs
@@ -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}}
diff --git a/assets/javascripts/wizard/templates/components/similar-topics-validator.hbs b/assets/javascripts/wizard/templates/components/similar-topics-validator.hbs
new file mode 100644
index 00000000..c19bc8c2
--- /dev/null
+++ b/assets/javascripts/wizard/templates/components/similar-topics-validator.hbs
@@ -0,0 +1,12 @@
+{{#if loading}}
+
+{{else if showSimilarTopics}}
+
+ {{wizard-similar-topics topics=similarTopics}}
+{{else if showNoSimilarTopics}}
+
+{{else if showDefault}}
+
+{{else}}
+
+{{/if}}
diff --git a/assets/javascripts/wizard/templates/components/validator.hbs b/assets/javascripts/wizard/templates/components/validator.hbs
new file mode 100644
index 00000000..09a4c262
--- /dev/null
+++ b/assets/javascripts/wizard/templates/components/validator.hbs
@@ -0,0 +1,5 @@
+{{#if isValid}}
+ {{i18n validMessageKey}}
+{{else}}
+ {{i18n invalidMessageKey}}
+{{/if}}
diff --git a/assets/javascripts/wizard/templates/components/wizard-field.hbs b/assets/javascripts/wizard/templates/components/wizard-field.hbs
index 5f524e92..b0563384 100644
--- a/assets/javascripts/wizard/templates/components/wizard-field.hbs
+++ b/assets/javascripts/wizard/templates/components/wizard-field.hbs
@@ -10,11 +10,13 @@
{{cookedDescription}}
{{/if}}
-{{#if inputComponentName}}
-
- {{component inputComponentName field=field step=step fieldClass=fieldClass wizard=wizard}}
-
-{{/if}}
+{{#field-validators field=field as |validators|}}
+ {{#if inputComponentName}}
+
+ {{component inputComponentName field=field step=step fieldClass=fieldClass wizard=wizard }}
+
+ {{/if}}
+{{/field-validators}}
{{#if field.char_counter}}
{{#if textType}}
diff --git a/assets/javascripts/wizard/templates/components/wizard-similar-topic.hbs b/assets/javascripts/wizard/templates/components/wizard-similar-topic.hbs
new file mode 100644
index 00000000..eeaaa751
--- /dev/null
+++ b/assets/javascripts/wizard/templates/components/wizard-similar-topic.hbs
@@ -0,0 +1,4 @@
+
+ {{html-safe topic.fancy_title}}
+ {{date-node topic.created_at}} - {{html-safe topic.blurb}}
+
diff --git a/assets/javascripts/wizard/templates/components/wizard-similar-topics.hbs b/assets/javascripts/wizard/templates/components/wizard-similar-topics.hbs
new file mode 100644
index 00000000..045f973d
--- /dev/null
+++ b/assets/javascripts/wizard/templates/components/wizard-similar-topics.hbs
@@ -0,0 +1,11 @@
+{{#if showTopics}}
+
+ {{#each topics as |topic|}}
+ - {{wizard-similar-topic topic=topic}}
+ {{/each}}
+
+{{else}}
+
+ {{i18n 'realtime_validations.similar_topics.show'}}
+
+{{/if}}
\ No newline at end of file
diff --git a/assets/stylesheets/common/wizard-admin.scss b/assets/stylesheets/common/wizard-admin.scss
index 8816039b..b974497c 100644
--- a/assets/stylesheets/common/wizard-admin.scss
+++ b/assets/stylesheets/common/wizard-admin.scss
@@ -642,4 +642,32 @@
}
}
}
-}
\ No newline at end of file
+}
+
+.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;
+}
diff --git a/assets/stylesheets/wizard/custom/field.scss b/assets/stylesheets/wizard/custom/field.scss
index c791d41a..6ad99046 100644
--- a/assets/stylesheets/wizard/custom/field.scss
+++ b/assets/stylesheets/wizard/custom/field.scss
@@ -155,4 +155,8 @@
.select-kit.combo-box.group-dropdown {
min-width: 220px;
}
+
+ .text-field input {
+ margin-bottom: 0;
+ }
}
\ No newline at end of file
diff --git a/assets/stylesheets/wizard/custom/validators.scss b/assets/stylesheets/wizard/custom/validators.scss
new file mode 100644
index 00000000..f5227688
--- /dev/null
+++ b/assets/stylesheets/wizard/custom/validators.scss
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/assets/stylesheets/wizard/wizard_custom.scss b/assets/stylesheets/wizard/wizard_custom.scss
index 103a4d2d..bedf4e0a 100644
--- a/assets/stylesheets/wizard/wizard_custom.scss
+++ b/assets/stylesheets/wizard/wizard_custom.scss
@@ -10,6 +10,7 @@
@import "custom/wizard";
@import "custom/step";
@import "custom/field";
+@import "custom/validators";
@import "custom/mobile";
@import "custom/autocomplete";
@import "custom/composer";
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 28304120..46633fc2 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -176,6 +176,15 @@ en:
date_time_format:
label: "Format"
instructions: "Moment.js format"
+ validations:
+ header: "Realtime Validations"
+ enabled: "Enabled"
+ similar_topics: "Similar Topics"
+ position: "Position"
+ above: "Above"
+ below: "Below"
+ categories: "Categories"
+ date_after: "Date After"
type:
text: "Text"
@@ -511,3 +520,11 @@ en:
yourself_confirm:
title: "Did you forget to add recipients?"
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"
diff --git a/config/routes.rb b/config/routes.rb
index 764c0fdd..110d54c5 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -9,6 +9,7 @@ end
Discourse::Application.routes.append do
mount ::CustomWizard::Engine, at: 'w'
post 'wizard/authorization/callback' => "custom_wizard/authorization#callback"
+ get 'realtime-validations' => 'custom_wizard/realtime_validations#validate'
scope module: 'custom_wizard', constraints: AdminConstraint.new do
get 'admin/wizards' => 'admin#index'
diff --git a/controllers/custom_wizard/admin/wizard.rb b/controllers/custom_wizard/admin/wizard.rb
index 9859f115..1bc4ece8 100644
--- a/controllers/custom_wizard/admin/wizard.rb
+++ b/controllers/custom_wizard/admin/wizard.rb
@@ -8,6 +8,7 @@ class CustomWizard::AdminWizardController < CustomWizard::AdminController
each_serializer: CustomWizard::BasicWizardSerializer
),
field_types: CustomWizard::Field.types,
+ realtime_validations: CustomWizard::RealtimeValidation.types,
custom_fields: custom_field_list
)
end
@@ -63,7 +64,7 @@ class CustomWizard::AdminWizardController < CustomWizard::AdminController
output: [],
]
end
-
+
def save_wizard_params
params.require(:wizard).permit(
:id,
@@ -104,7 +105,8 @@ class CustomWizard::AdminWizardController < CustomWizard::AdminController
:limit,
:property,
prefill: mapped_params,
- content: mapped_params
+ content: mapped_params,
+ validations: {},
]
],
actions: [
diff --git a/controllers/custom_wizard/realtime_validations.rb b/controllers/custom_wizard/realtime_validations.rb
new file mode 100644
index 00000000..183e7ccd
--- /dev/null
+++ b/controllers/custom_wizard/realtime_validations.rb
@@ -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
diff --git a/coverage/.last_run.json b/coverage/.last_run.json
index 6e843e2c..c135c6ad 100644
--- a/coverage/.last_run.json
+++ b/coverage/.last_run.json
@@ -1,5 +1,5 @@
{
"result": {
- "covered_percent": 89.45
+ "line": 89.57
}
}
diff --git a/extensions/wizard_field.rb b/extensions/wizard_field.rb
index 5b73c6a3..e62f5d9a 100644
--- a/extensions/wizard_field.rb
+++ b/extensions/wizard_field.rb
@@ -4,6 +4,7 @@ module CustomWizardFieldExtension
:description,
:image,
:key,
+ :validations,
:min_length,
:max_length,
:char_counter,
@@ -20,6 +21,7 @@ module CustomWizardFieldExtension
@description = attrs[:description]
@image = attrs[:image]
@key = attrs[:key]
+ @validations = attrs[:validations]
@min_length = attrs[:min_length]
@max_length = attrs[:max_length]
@char_counter = attrs[:char_counter]
diff --git a/lib/custom_wizard/builder.rb b/lib/custom_wizard/builder.rb
index 996b0737..70078b30 100644
--- a/lib/custom_wizard/builder.rb
+++ b/lib/custom_wizard/builder.rb
@@ -158,6 +158,7 @@ class CustomWizard::Builder
params[:description] = field_template['description'] if field_template['description']
params[:image] = field_template['image'] if field_template['image']
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[:max_length] = field_template['max_length'] if field_template['max_length']
params[:char_counter] = field_template['char_counter'] if field_template['char_counter']
diff --git a/lib/custom_wizard/field.rb b/lib/custom_wizard/field.rb
index 0c19b321..f52e2d5a 100644
--- a/lib/custom_wizard/field.rb
+++ b/lib/custom_wizard/field.rb
@@ -5,7 +5,8 @@ class CustomWizard::Field
min_length: nil,
max_length: nil,
prefill: nil,
- char_counter: nil
+ char_counter: nil,
+ validations: nil
},
textarea: {
min_length: nil,
diff --git a/lib/custom_wizard/realtime_validation.rb b/lib/custom_wizard/realtime_validation.rb
new file mode 100644
index 00000000..69341ebf
--- /dev/null
+++ b/lib/custom_wizard/realtime_validation.rb
@@ -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
diff --git a/lib/custom_wizard/realtime_validations/result.rb b/lib/custom_wizard/realtime_validations/result.rb
new file mode 100644
index 00000000..988bea73
--- /dev/null
+++ b/lib/custom_wizard/realtime_validations/result.rb
@@ -0,0 +1,11 @@
+class CustomWizard::RealtimeValidation::Result
+ attr_accessor :type,
+ :items,
+ :serializer_opts
+
+ def initialize(type)
+ @type = type
+ @items = []
+ @serializer_opts = {}
+ end
+end
\ No newline at end of file
diff --git a/lib/custom_wizard/realtime_validations/similar_topics.rb b/lib/custom_wizard/realtime_validations/similar_topics.rb
new file mode 100644
index 00000000..29425f5b
--- /dev/null
+++ b/lib/custom_wizard/realtime_validations/similar_topics.rb
@@ -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
\ No newline at end of file
diff --git a/plugin.rb b/plugin.rb
index 52b8348c..95740607 100644
--- a/plugin.rb
+++ b/plugin.rb
@@ -47,6 +47,7 @@ after_initialize do
../controllers/custom_wizard/admin/custom_fields.rb
../controllers/custom_wizard/wizard.rb
../controllers/custom_wizard/steps.rb
+ ../controllers/custom_wizard/realtime_validations.rb
../jobs/clear_after_time_wizard.rb
../jobs/refresh_api_access_token.rb
../jobs/set_after_time_wizard.rb
@@ -58,6 +59,9 @@ after_initialize do
../lib/custom_wizard/cache.rb
../lib/custom_wizard/custom_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/log.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_serializer.rb
../serializers/custom_wizard/log_serializer.rb
+ ../serializers/custom_wizard/realtime_validation/similar_topics_serializer.rb
../extensions/extra_locales_controller.rb
../extensions/invites_controller.rb
../extensions/users_controller.rb
diff --git a/serializers/custom_wizard/realtime_validation/similar_topics_serializer.rb b/serializers/custom_wizard/realtime_validation/similar_topics_serializer.rb
new file mode 100644
index 00000000..976eef1b
--- /dev/null
+++ b/serializers/custom_wizard/realtime_validation/similar_topics_serializer.rb
@@ -0,0 +1,2 @@
+class ::CustomWizard::RealtimeValidation::SimilarTopicsSerializer < ::SimilarTopicSerializer
+end
\ No newline at end of file
diff --git a/serializers/custom_wizard/wizard_field_serializer.rb b/serializers/custom_wizard/wizard_field_serializer.rb
index 805b0a88..e89d5f0c 100644
--- a/serializers/custom_wizard/wizard_field_serializer.rb
+++ b/serializers/custom_wizard/wizard_field_serializer.rb
@@ -8,6 +8,7 @@ class CustomWizard::FieldSerializer < ::WizardFieldSerializer
:limit,
:property,
:content,
+ :validations,
:max_length,
:char_counter,
:number
@@ -58,6 +59,17 @@ class CustomWizard::FieldSerializer < ::WizardFieldSerializer
object.choices.present?
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
object.max_length
end
diff --git a/spec/components/custom_wizard/realtime_validation_spec.rb b/spec/components/custom_wizard/realtime_validation_spec.rb
new file mode 100644
index 00000000..819ac2ae
--- /dev/null
+++ b/spec/components/custom_wizard/realtime_validation_spec.rb
@@ -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
diff --git a/spec/components/custom_wizard/realtime_validations/similar_topics_spec.rb b/spec/components/custom_wizard/realtime_validations/similar_topics_spec.rb
new file mode 100644
index 00000000..e76b7fdb
--- /dev/null
+++ b/spec/components/custom_wizard/realtime_validations/similar_topics_spec.rb
@@ -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
diff --git a/spec/requests/custom_wizard/realtime_validations_spec.rb b/spec/requests/custom_wizard/realtime_validations_spec.rb
new file mode 100644
index 00000000..0d59e885
--- /dev/null
+++ b/spec/requests/custom_wizard/realtime_validations_spec.rb
@@ -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
diff --git a/spec/serializers/custom_wizard/wizard_field_serializer_spec.rb b/spec/serializers/custom_wizard/wizard_field_serializer_spec.rb
index 3398a9dc..73de1f6b 100644
--- a/spec/serializers/custom_wizard/wizard_field_serializer_spec.rb
+++ b/spec/serializers/custom_wizard/wizard_field_serializer_spec.rb
@@ -33,6 +33,6 @@ describe CustomWizard::FieldSerializer do
).as_json
expect(json_array[0][:format]).to eq("YYYY-MM-DD")
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
\ No newline at end of file