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'}}

+ + 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}} + +{{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