diff --git a/app/assets/javascripts/admin/addon/components/modal/badge-preview.js b/app/assets/javascripts/admin/addon/components/modal/badge-preview.js index 0139fe4f356..6641803ea78 100644 --- a/app/assets/javascripts/admin/addon/components/modal/badge-preview.js +++ b/app/assets/javascripts/admin/addon/components/modal/badge-preview.js @@ -42,7 +42,7 @@ export default class BadgePreview extends Component { } get queryPlanHtml() { - let output = `
`;
+    let output = `
`;
     this.args.model.badge.query_plan.forEach((linehash) => {
       output += escapeExpression(linehash["QUERY PLAN"]);
       output += "
"; diff --git a/app/assets/javascripts/admin/addon/controllers/admin-badges/show.js b/app/assets/javascripts/admin/addon/controllers/admin-badges/show.js index 2616b111007..78af7fb4f97 100644 --- a/app/assets/javascripts/admin/addon/controllers/admin-badges/show.js +++ b/app/assets/javascripts/admin/addon/controllers/admin-badges/show.js @@ -1,35 +1,61 @@ -import { tracked } from "@glimmer/tracking"; +import { cached, tracked } from "@glimmer/tracking"; import Controller, { inject as controller } from "@ember/controller"; -import { action } from "@ember/object"; -import { next } from "@ember/runloop"; +import { action, getProperties } from "@ember/object"; import { service } from "@ember/service"; -import { observes } from "@ember-decorators/object"; +import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; -import { bufferedProperty } from "discourse/mixins/buffered-content"; import getURL from "discourse-common/lib/get-url"; import I18n from "discourse-i18n"; +import BadgePreviewModal from "../../components/modal/badge-preview"; -const IMAGE = "image"; -const ICON = "icon"; +const FORM_FIELDS = [ + "allow_title", + "multiple_grant", + "listable", + "auto_revoke", + "enabled", + "show_posts", + "target_posts", + "name", + "description", + "long_description", + "icon", + "image_upload_id", + "query", + "badge_grouping_id", + "trigger", + "badge_type_id", +]; -// TODO: Stop using Mixin here -export default class AdminBadgesShowController extends Controller.extend( - bufferedProperty("model") -) { +export default class AdminBadgesShowController extends Controller { @service router; + @service toasts; @service dialog; + @service modal; + @controller adminBadges; - @tracked saving = false; - @tracked savingStatus = ""; + @tracked model; + @tracked previewLoading = false; @tracked selectedGraphicType = null; + @tracked userBadges; + @tracked userBadgesAll; - get badgeEnabledLabel() { - if (this.buffered.get("enabled")) { - return "admin.badges.enabled"; - } else { - return "admin.badges.disabled"; + @cached + get formData() { + const data = getProperties(this.model, ...FORM_FIELDS); + + if (data.icon === "") { + data.icon = undefined; } + + return data; + } + + @action + currentBadgeGrouping(data) { + return this.badgeGroupings.find((bg) => bg.id === data.badge_grouping_id) + ?.name; } get badgeTypes() { @@ -49,212 +75,149 @@ export default class AdminBadgesShowController extends Controller.extend( } get readOnly() { - return this.buffered.get("system"); + return this.model.system; } - get showDisplayName() { - return this.name !== this.displayName; - } - - get iconSelectorSelected() { - return this.selectedGraphicType === ICON; - } - - get imageUploaderSelected() { - return this.selectedGraphicType === IMAGE; - } - - init() { - super.init(...arguments); - + setup() { // this is needed because the model doesnt have default values - // and as we are using a bufferedProperty it's not accessible - // in any other way - next(() => { - // Using `set` here isn't ideal, but we don't know that tracking is set up on the model yet. - if (this.model) { - if (!this.model.badge_type_id) { - this.model.set("badge_type_id", this.badgeTypes?.[0]?.id); - } - - if (!this.model.badge_grouping_id) { - this.model.set("badge_grouping_id", this.badgeGroupings?.[0]?.id); - } - - if (!this.model.trigger) { - this.model.set("trigger", this.badgeTriggers?.[0]?.id); - } + // Using `set` here isn't ideal, but we don't know that tracking is set up on the model yet. + if (this.model) { + if (!this.model.badge_type_id) { + this.model.set("badge_type_id", this.badgeTypes?.[0]?.id); } - }); + + if (!this.model.badge_grouping_id) { + this.model.set("badge_grouping_id", this.badgeGroupings?.[0]?.id); + } + + if (!this.model.trigger) { + this.model.set("trigger", this.badgeTriggers?.[0]?.id); + } + } } - get hasQuery() { - let modelQuery = this.model.get("query"); - let bufferedQuery = this.buffered.get("query"); - - if (bufferedQuery) { - return bufferedQuery.trim().length > 0; - } - return modelQuery && modelQuery.trim().length > 0; + hasQuery(query) { + return query?.trim?.()?.length > 0; } get textCustomizationPrefix() { return `badges.${this.model.i18n_name}.`; } - // FIXME: Remove observer - @observes("model.id") - _resetSaving() { - this.saving = false; - this.savingStatus = ""; - } - - showIconSelector() { - this.selectedGraphicType = ICON; - } - - showImageUploader() { - this.selectedGraphicType = IMAGE; - } - @action - changeGraphicType(newType) { - if (newType === IMAGE) { - this.showImageUploader(); - } else if (newType === ICON) { - this.showIconSelector(); + onSetImage(upload, { set }) { + if (upload) { + set("image_upload_id", upload.id); + set("image_url", getURL(upload.url)); + set("icon", null); } else { - throw new Error(`Unknown badge graphic type "${newType}"`); + set("image_upload_id", ""); + set("image_url", ""); } } @action - setImage(upload) { - this.buffered.set("image_upload_id", upload.id); - this.buffered.set("image_url", getURL(upload.url)); - } - - @action - removeImage() { - this.buffered.set("image_upload_id", null); - this.buffered.set("image_url", null); + onSetIcon(value, { set }) { + set("icon", value); + set("image_upload_id", ""); + set("image_url", ""); } @action showPreview(badge, explain, event) { event?.preventDefault(); - this.send("preview", badge, explain); + this.preview(badge, explain); } @action - save() { - if (!this.saving) { - let fields = [ - "allow_title", - "multiple_grant", - "listable", - "auto_revoke", - "enabled", - "show_posts", - "target_posts", - "name", - "description", - "long_description", - "icon", - "image_upload_id", - "query", - "badge_grouping_id", - "trigger", - "badge_type_id", - ]; + validateForm(data, { addError }) { + if (!data.icon && !data.image_url) { + addError("icon", { + title: "Icon", + message: I18n.t("admin.badges.icon_or_image"), + }); + addError("image_url", { + title: "Image", + message: I18n.t("admin.badges.icon_or_image"), + }); + } + } - if (this.buffered.get("system")) { - let protectedFields = this.protectedSystemFields || []; - fields = fields.filter((f) => !protectedFields.includes(f)); - } - - this.saving = true; - this.savingStatus = I18n.t("saving"); - - const boolFields = [ - "allow_title", - "multiple_grant", - "listable", - "auto_revoke", - "enabled", - "show_posts", - "target_posts", - ]; - - const data = {}; - const buffered = this.buffered; - fields.forEach(function (field) { - let d = buffered.get(field); - if (boolFields.includes(field)) { - d = !!d; - } - data[field] = d; + @action + async preview(badge, explain) { + try { + this.previewLoading = true; + const model = await ajax("/admin/badges/preview.json", { + type: "POST", + data: { + sql: badge.query, + target_posts: !!badge.target_posts, + trigger: badge.trigger, + explain, + }, }); - const newBadge = !this.id; - const model = this.model; - this.model - .save(data) - .then(() => { - if (newBadge) { - const adminBadges = this.get("adminBadges.model"); - if (!adminBadges.includes(model)) { - adminBadges.pushObject(model); - } - this.router.transitionTo("adminBadges.show", model.get("id")); - } else { - this.commitBuffer(); - this.savingStatus = I18n.t("saved"); - } - }) - .catch(popupAjaxError) - .finally(() => { - this.saving = false; - this.savingStatus = ""; - }); + this.modal.show(BadgePreviewModal, { model: { badge: model } }); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + this.dialog.alert("Network error"); + } finally { + this.previewLoading = false; } } @action - destroyBadge() { - const adminBadges = this.adminBadges.model; - const model = this.model; + async handleSubmit(formData) { + let fields = FORM_FIELDS; - if (!model?.get("id")) { - this.router.transitionTo("adminBadges.index"); - return; + if (formData.system) { + const protectedFields = this.protectedSystemFields || []; + fields = fields.filter((f) => !protectedFields.includes(f)); } + const data = {}; + fields.forEach(function (field) { + data[field] = formData[field]; + }); + + const newBadge = !this.model.id; + + try { + this.model = await this.model.save(data); + + this.toasts.success({ data: { message: I18n.t("saved") } }); + + if (newBadge) { + const adminBadges = this.get("adminBadges.model"); + if (!adminBadges.includes(this.model)) { + adminBadges.pushObject(this.model); + } + return this.router.transitionTo("adminBadges.show", this.model.id); + } + } catch (error) { + return popupAjaxError(error); + } + } + + @action + async handleDelete() { + if (!this.model?.id) { + return this.router.transitionTo("adminBadges.index"); + } + + const adminBadges = this.adminBadges.model; return this.dialog.yesNoConfirm({ message: I18n.t("admin.badges.delete_confirm"), - didConfirm: () => { - model - .destroy() - .then(() => { - adminBadges.removeObject(model); - this.router.transitionTo("adminBadges.index"); - }) - .catch(() => { - this.dialog.alert(I18n.t("generic_error")); - }); + didConfirm: async () => { + try { + await this.model.destroy(); + adminBadges.removeObject(this.model); + this.router.transitionTo("adminBadges.index"); + } catch { + this.dialog.alert(I18n.t("generic_error")); + } }, }); } - - @action - toggleBadge() { - const originalState = this.buffered.get("enabled"); - const newState = !this.buffered.get("enabled"); - - this.buffered.set("enabled", newState); - this.model.save({ enabled: newState }).catch((error) => { - this.buffered.set("enabled", originalState); - return popupAjaxError(error); - }); - } } diff --git a/app/assets/javascripts/admin/addon/routes/admin-badges.js b/app/assets/javascripts/admin/addon/routes/admin-badges.js index 15040ae4b16..80b3ead0c05 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-badges.js +++ b/app/assets/javascripts/admin/addon/routes/admin-badges.js @@ -1,10 +1,15 @@ +import { action } from "@ember/object"; +import { service } from "@ember/service"; import { ajax } from "discourse/lib/ajax"; import Badge from "discourse/models/badge"; import BadgeGrouping from "discourse/models/badge-grouping"; import DiscourseRoute from "discourse/routes/discourse"; import I18n from "discourse-i18n"; +import EditBadgeGroupingsModal from "../components/modal/edit-badge-groupings"; export default class AdminBadgesRoute extends DiscourseRoute { + @service modal; + _json = null; async model() { @@ -13,6 +18,17 @@ export default class AdminBadgesRoute extends DiscourseRoute { return Badge.createFromJson(json); } + @action + editGroupings() { + const model = this.controllerFor("admin-badges").badgeGroupings; + this.modal.show(EditBadgeGroupingsModal, { + model: { + badgeGroupings: model, + updateGroupings: this.updateGroupings, + }, + }); + } + setupController(controller, model) { const json = this._json; const badgeTriggers = []; diff --git a/app/assets/javascripts/admin/addon/routes/admin-badges/show.js b/app/assets/javascripts/admin/addon/routes/admin-badges/show.js index 2d5f7914bb2..621e64035a3 100644 --- a/app/assets/javascripts/admin/addon/routes/admin-badges/show.js +++ b/app/assets/javascripts/admin/addon/routes/admin-badges/show.js @@ -1,15 +1,11 @@ import { action, get } from "@ember/object"; import Route from "@ember/routing/route"; import { service } from "@ember/service"; -import { ajax } from "discourse/lib/ajax"; import Badge from "discourse/models/badge"; import I18n from "discourse-i18n"; -import BadgePreviewModal from "../../components/modal/badge-preview"; -import EditBadgeGroupingsModal from "../../components/modal/edit-badge-groupings"; export default class AdminBadgesShowRoute extends Route { @service dialog; - @service modal; serialize(m) { return { badge_id: get(m, "id") || "new" }; @@ -27,51 +23,14 @@ export default class AdminBadgesShowRoute extends Route { ); } - setupController(controller, model) { + setupController(controller) { super.setupController(...arguments); - if (model.image_url) { - controller.showImageUploader(); - } else if (model.icon) { - controller.showIconSelector(); - } - } - @action - editGroupings() { - const model = this.controllerFor("admin-badges").get("badgeGroupings"); - this.modal.show(EditBadgeGroupingsModal, { - model: { - badgeGroupings: model, - updateGroupings: this.updateGroupings, - }, - }); + controller.setup(); } @action updateGroupings(groupings) { this.controllerFor("admin-badges").set("badgeGroupings", groupings); } - - @action - async preview(badge, explain) { - try { - badge.set("preview_loading", true); - const model = await ajax("/admin/badges/preview.json", { - type: "POST", - data: { - sql: badge.get("query"), - target_posts: !!badge.get("target_posts"), - trigger: badge.get("trigger"), - explain, - }, - }); - badge.set("preview_loading", false); - this.modal.show(BadgePreviewModal, { model: { badge: model } }); - } catch (e) { - badge.set("preview_loading", false); - // eslint-disable-next-line no-console - console.error(e); - this.dialog.alert("Network error"); - } - } } diff --git a/app/assets/javascripts/admin/addon/templates/admin-badges.hbs b/app/assets/javascripts/admin/addon/templates/admin-badges.hbs index 8eb268a632d..6999356594a 100644 --- a/app/assets/javascripts/admin/addon/templates/admin-badges.hbs +++ b/app/assets/javascripts/admin/addon/templates/admin-badges.hbs @@ -2,14 +2,24 @@

{{i18n "admin.badges.title"}}

- - {{d-icon "plus"}} - {{i18n "admin.badges.new"}} - {{d-icon "upload"}} - {{i18n "admin.badges.mass_award.title"}} + {{i18n + "admin.badges.mass_award.title" + }} + + + + + + {{d-icon "plus"}} + {{i18n "admin.badges.new"}}
diff --git a/app/assets/javascripts/admin/addon/templates/admin-badges/show.hbs b/app/assets/javascripts/admin/addon/templates/admin-badges/show.hbs index 7542aa88b2e..9995d141365 100644 --- a/app/assets/javascripts/admin/addon/templates/admin-badges/show.hbs +++ b/app/assets/javascripts/admin/addon/templates/admin-badges/show.hbs @@ -1,311 +1,328 @@ -
-
- -
+
- -
- - {{#if this.readOnly}} - -

- - {{i18n "admin.badges.read_only_setting_help"}} - -

- {{else}} - - {{/if}} -
+

+ {{iconOrImage data}} + {{data.name}} +

-
- -
- - - -
- {{#if this.imageUploaderSelected}} - -
-

{{i18n "admin.badges.image_help"}}

-
- {{else if this.iconSelectorSelected}} - - {{/if}} -
- -
- - + {{i18n "admin.badges.disable_system"}} + + {{else}} + + -
+ + {{/if}} -
- + {{#if this.readOnly}} + + + {{this.model.name}} + + + {{d-icon "pencil-alt"}} + + + {{else}} + + + + {{/if}} -
- - -
-
+ + + + {{#each this.badgeTypes as |badgeType|}} + + {{badgeType.name}} + + {{/each}} + + -
- - {{#if this.buffered.system}} - + +} diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/toggle.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/toggle.gjs new file mode 100644 index 00000000000..fb78824089e --- /dev/null +++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/toggle.gjs @@ -0,0 +1,21 @@ +import Component from "@glimmer/component"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import DToggleSwitch from "discourse/components/d-toggle-switch"; + +export default class FKControlToggle extends Component { + static controlType = "toggle"; + + @action + handleInput() { + this.args.field.set(!this.args.value); + } + + +} diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/errors-summary.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/errors-summary.gjs new file mode 100644 index 00000000000..1d43f4cf63a --- /dev/null +++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/errors-summary.gjs @@ -0,0 +1,41 @@ +import Component from "@glimmer/component"; +import { concat } from "@ember/helper"; +import icon from "discourse-common/helpers/d-icon"; +import i18n from "discourse-common/helpers/i18n"; + +export default class FKErrorsSummary extends Component { + concatErrors(errors) { + return errors.join(", "); + } + + get hasErrors() { + return Object.keys(this.args.errors).length > 0; + } + + normalizeName(name) { + return name.replace(/\./g, "-"); + } + + +} diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/errors.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/errors.gjs new file mode 100644 index 00000000000..b6e8ed5ccb9 --- /dev/null +++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/errors.gjs @@ -0,0 +1,17 @@ +import Component from "@glimmer/component"; +import icon from "discourse-common/helpers/d-icon"; + +export default class FKErrors extends Component { + concatErrors(errors) { + return errors.join(", "); + } + + +} diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/field.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/field.gjs new file mode 100644 index 00000000000..c17082673da --- /dev/null +++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/field.gjs @@ -0,0 +1,206 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { hash } from "@ember/helper"; +import FKControlCheckbox from "discourse/form-kit/components/fk/control/checkbox"; +import FKControlCode from "discourse/form-kit/components/fk/control/code"; +import FKControlComposer from "discourse/form-kit/components/fk/control/composer"; +import FKControlIcon from "discourse/form-kit/components/fk/control/icon"; +import FKControlImage from "discourse/form-kit/components/fk/control/image"; +import FKControlInput from "discourse/form-kit/components/fk/control/input"; +import FKControlMenu from "discourse/form-kit/components/fk/control/menu"; +import FKControlPassword from "discourse/form-kit/components/fk/control/password"; +import FKControlQuestion from "discourse/form-kit/components/fk/control/question"; +import FKControlRadioGroup from "discourse/form-kit/components/fk/control/radio-group"; +import FKControlSelect from "discourse/form-kit/components/fk/control/select"; +import FKControlTextarea from "discourse/form-kit/components/fk/control/textarea"; +import FKControlToggle from "discourse/form-kit/components/fk/control/toggle"; +import FKControlWrapper from "discourse/form-kit/components/fk/control-wrapper"; +import FKRow from "discourse/form-kit/components/fk/row"; + +export default class FKField extends Component { + @tracked field; + @tracked name; + + constructor() { + super(...arguments); + + if (!this.args.title?.length) { + throw new Error("@title is required on ``."); + } + + if (typeof this.args.name !== "string") { + throw new Error( + "@name is required and must be a string on ``." + ); + } + + if (this.args.name.includes(".") || this.args.name.includes("-")) { + throw new Error("@name can't include `.` or `-`."); + } + + this.name = + (this.args.collectionName ? `${this.args.collectionName}.` : "") + + (this.args.collectionIndex !== undefined + ? `${this.args.collectionIndex}.` + : "") + + this.args.name; + + this.field = this.args.registerField(this.name, { + triggerRevalidationFor: this.args.triggerRevalidationFor, + title: this.args.title, + showTitle: this.args.showTitle, + collectionIndex: this.args.collectionIndex, + set: this.args.set, + addError: this.args.addError, + validate: this.args.validate, + disabled: this.args.disabled, + validation: this.args.validation, + onSet: this.args.onSet, + }); + } + + willDestroy() { + this.args.unregisterField(this.name); + + super.willDestroy(); + } + + get value() { + return this.args.data.get(this.name); + } + + get wrapper() { + if (this.args.size) { + return ; + } else { + return ; + } + } + + +} diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/form.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/form.gjs new file mode 100644 index 00000000000..e50f27976df --- /dev/null +++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/form.gjs @@ -0,0 +1,311 @@ +import Component from "@glimmer/component"; +import { tracked } from "@glimmer/tracking"; +import { array, hash } from "@ember/helper"; +import { on } from "@ember/modifier"; +import { action } from "@ember/object"; +import { service } from "@ember/service"; +import DButton from "discourse/components/d-button"; +import FKAlert from "discourse/form-kit/components/fk/alert"; +import FKCollection from "discourse/form-kit/components/fk/collection"; +import FKContainer from "discourse/form-kit/components/fk/container"; +import FKControlConditionalContent from "discourse/form-kit/components/fk/control/conditional-content"; +import FKControlInputGroup from "discourse/form-kit/components/fk/control/input-group"; +import FKErrorsSummary from "discourse/form-kit/components/fk/errors-summary"; +import FKField from "discourse/form-kit/components/fk/field"; +import Row from "discourse/form-kit/components/fk/row"; +import FKSection from "discourse/form-kit/components/fk/section"; +import { VALIDATION_TYPES } from "discourse/form-kit/lib/constants"; +import FKFieldData from "discourse/form-kit/lib/fk-field-data"; +import FKFormData from "discourse/form-kit/lib/fk-form-data"; +import I18n from "I18n"; + +class FKForm extends Component { + @service dialog; + @service router; + + @tracked isValidating = false; + + @tracked isSubmitting = false; + + fields = new Map(); + + formData = new FKFormData(this.args.data ?? {}); + + constructor() { + super(...arguments); + + this.args.onRegisterApi?.({ + set: this.set, + submit: this.onSubmit, + reset: this.onReset, + }); + + this.router.on("routeWillChange", this.checkIsDirty); + } + + willDestroy() { + super.willDestroy(); + + this.router.off("routeWillChange", this.checkIsDirty); + } + + @action + async checkIsDirty(transition) { + if ( + this.formData.isDirty && + !transition.isAborted && + !transition.queryParamsOnly + ) { + transition.abort(); + + this.dialog.yesNoConfirm({ + message: I18n.t("form_kit.dirty_form"), + didConfirm: async () => { + await this.onReset(); + transition.retry(); + }, + }); + } + } + + get validateOn() { + return this.args.validateOn ?? VALIDATION_TYPES.submit; + } + + get fieldValidationEvent() { + const { validateOn } = this; + + if (validateOn === VALIDATION_TYPES.submit) { + return undefined; + } + + return validateOn; + } + + @action + addError(name, { title, message }) { + this.formData.addError(name, { + title, + message, + }); + } + + @action + async addItemToCollection(name, value = {}) { + const current = this.formData.get(name) ?? []; + this.formData.set(name, current.concat(value)); + } + + @action + async remove(name, index) { + const current = this.formData.get(name) ?? []; + + this.formData.set( + name, + current.filter((_, i) => i !== index) + ); + + Object.keys(this.formData.errors).forEach((key) => { + if (key.startsWith(`${name}.${index}.`)) { + this.formData.removeError(key); + } + }); + } + + @action + async set(name, value) { + this.formData.set(name, value); + + if (this.fieldValidationEvent === VALIDATION_TYPES.change) { + await this.triggerRevalidationFor(name); + } + } + + @action + registerField(name, field) { + if (!name) { + throw new Error("@name is required on ``."); + } + + if (this.fields.has(name)) { + throw new Error( + `@name="${name}", is already in use. Names of \`\` must be unique!` + ); + } + + const fieldModel = new FKFieldData(name, field); + this.fields.set(name, fieldModel); + + return fieldModel; + } + + @action + unregisterField(name) { + this.fields.delete(name); + } + + @action + async onSubmit(event) { + event?.preventDefault(); + + if (this.isSubmitting) { + return; + } + + try { + this.isSubmitting = true; + + await this.validate(this.fields.values()); + + if (this.formData.isValid) { + this.formData.save(); + + await this.args.onSubmit?.(this.formData.draftData); + } + } finally { + this.isSubmitting = false; + } + } + + @action + async onReset(event) { + event?.preventDefault(); + + this.formData.removeErrors(); + await this.formData.rollback(); + await this.args.onReset?.(this.formData.draftData); + } + + @action + async triggerRevalidationFor(name) { + const field = this.fields.get(name); + + if (!field) { + return; + } + + if (this.formData.errors[name]) { + await this.validate([field]); + } + } + + async validate(fields) { + if (this.isValidating) { + return; + } + + this.isValidating = true; + + try { + for (const field of fields) { + this.formData.removeError(field.name); + + await field.validate?.( + field.name, + this.formData.get(field.name), + this.formData.draftData + ); + } + + await this.args.validate?.(this.formData.draftData, { + addError: this.addError, + }); + } finally { + this.isValidating = false; + } + } + + +} + +const Form = ; + +export default Form; diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/label.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/label.gjs new file mode 100644 index 00000000000..3d86ec177c8 --- /dev/null +++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/label.gjs @@ -0,0 +1,7 @@ +const FKLabel = ; + +export default FKLabel; diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/meta.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/meta.gjs new file mode 100644 index 00000000000..9adcf4b5e3c --- /dev/null +++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/meta.gjs @@ -0,0 +1,43 @@ +import Component from "@glimmer/component"; +import FKCharCounter from "discourse/form-kit/components/fk/char-counter"; +import FKErrors from "discourse/form-kit/components/fk/errors"; +import FKText from "discourse/form-kit/components/fk/text"; + +export default class FKMeta extends Component { + get shouldRenderCharCounter() { + return this.args.field.maxLength > 0 && !this.args.field.disabled; + } + + get shouldRenderMeta() { + return ( + this.showMeta && + (this.shouldRenderCharCounter || + this.args.error || + this.args.description?.length) + ); + } + + get showMeta() { + return this.args.showMeta ?? true; + } + + +} diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/row.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/row.gjs new file mode 100644 index 00000000000..57ea5b6e6b8 --- /dev/null +++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/row.gjs @@ -0,0 +1,10 @@ +import { hash } from "@ember/helper"; +import FKCol from "discourse/form-kit/components/fk/col"; + +const FKRow = ; + +export default FKRow; diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/section.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/section.gjs new file mode 100644 index 00000000000..e69da76f079 --- /dev/null +++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/section.gjs @@ -0,0 +1,17 @@ +import concatClass from "discourse/helpers/concat-class"; + +const FKSection = ; + +export default FKSection; diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/text.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/text.gjs new file mode 100644 index 00000000000..2369ac2d2a1 --- /dev/null +++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/text.gjs @@ -0,0 +1,7 @@ +const FKText = ; + +export default FKText; diff --git a/app/assets/javascripts/discourse/app/form-kit/lib/constants.js b/app/assets/javascripts/discourse/app/form-kit/lib/constants.js new file mode 100644 index 00000000000..3b1dbe8b15f --- /dev/null +++ b/app/assets/javascripts/discourse/app/form-kit/lib/constants.js @@ -0,0 +1,8 @@ +export const VALIDATION_TYPES = { + submit: "submit", + change: "change", + focusout: "focusout", + input: "input", +}; + +export const NO_VALUE_OPTION = "__NONE__"; diff --git a/app/assets/javascripts/discourse/app/form-kit/lib/fk-field-data.js b/app/assets/javascripts/discourse/app/form-kit/lib/fk-field-data.js new file mode 100644 index 00000000000..fe834678aa6 --- /dev/null +++ b/app/assets/javascripts/discourse/app/form-kit/lib/fk-field-data.js @@ -0,0 +1,139 @@ +import ValidationParser from "discourse/form-kit/lib/validation-parser"; +import Validator from "discourse/form-kit/lib/validator"; +import uniqueId from "discourse/helpers/unique-id"; + +/** + * Represents field data for a form. + */ +export default class FKFieldData { + /** + * Unique identifier for the field. + * @type {string} + */ + id = uniqueId(); + + /** + * Unique identifier for the field error. + * @type {Function} + */ + errorId = uniqueId(); + + /** + * Type of the field. + * @type {string} + */ + type; + + /** + * Creates an instance of FieldData. + * @param {string} name - The name of the field. + * @param {Object} options - The options for the field. + * @param {Function} options.set - The callback function for setting the field value. + * @param {Function} options.onSet - The callback function for setting the custom field value. + * @param {string} options.validation - The validation rules for the field. + * @param {boolean} [options.disabled=false] - Indicates if the field is disabled. + * @param {Function} [options.validate] - The custom validation function. + * @param {Function} [options.title] - The custom field title. + * @param {Function} [options.showTitle=true] - Indicates if the field title should be shown. + * @param {Function} [options.triggerRevalidationFor] - The function to trigger revalidation. + * @param {Function} [options.addError] - The function to add an error message. + */ + constructor( + name, + { + set, + onSet, + validation, + disabled = false, + validate, + title, + showTitle = true, + triggerRevalidationFor, + collectionIndex, + addError, + } + ) { + this.name = name; + this.title = title; + this.collectionIndex = collectionIndex; + this.addError = addError; + this.showTitle = showTitle; + this.disabled = disabled; + this.customValidate = validate; + this.validation = validation; + this.rules = this.validation ? ValidationParser.parse(validation) : null; + this.set = (value) => { + if (onSet) { + onSet(value, { set, index: collectionIndex }); + } else { + set(this.name, value, { index: collectionIndex }); + } + + triggerRevalidationFor(name); + }; + } + + /** + * Checks if the field is required. + * @type {boolean} + * @readonly + */ + get required() { + return this.rules?.required ?? false; + } + + /** + * Sets the type of the field. + */ + setType(type) { + this.type = type; + } + + /** + * Gets the maximum length of the field value. + * @type {number|null} + * @readonly + */ + get maxLength() { + return this.rules?.length?.max ?? null; + } + + /** + * Gets the minimum length of the field value. + * @type {number|null} + * @readonly + */ + get minLength() { + return this.rules?.length?.min ?? null; + } + + /** + * Validates the field value. + * @param {string} name - The name of the field. + * @param {any} value - The value of the field. + * @param {Object} data - Additional data for validation. + * @returns {Promise} The validation errors. + */ + async validate(name, value, data) { + if (this.disabled) { + return; + } + + await this.customValidate?.(name, value, { + data, + type: this.type, + addError: this.addError, + }); + + const validator = new Validator(value, this.rules); + const validationErrors = await validator.validate(this.type); + validationErrors.forEach((message) => { + let title = this.title; + if (this.collectionIndex !== undefined) { + title += ` #${this.collectionIndex + 1}`; + } + + this.addError(name, { title, message }); + }); + } +} diff --git a/app/assets/javascripts/discourse/app/form-kit/lib/fk-form-data.js b/app/assets/javascripts/discourse/app/form-kit/lib/fk-form-data.js new file mode 100644 index 00000000000..dde9381a030 --- /dev/null +++ b/app/assets/javascripts/discourse/app/form-kit/lib/fk-form-data.js @@ -0,0 +1,207 @@ +/** + * A Changeset class that manages data and tracks changes. + */ +import { tracked } from "@glimmer/tracking"; +import { next } from "@ember/runloop"; +import { applyPatches, enablePatches, produce } from "immer"; + +enablePatches(); + +export default class FKFormData { + /** + * The original data. + * @type {any} + */ + @tracked data; + + /** + * The draft data, stores the changes made to original data, without mutating original data. + * @type {any} + */ + @tracked draftData; + + /** + * The errors associated with the changeset. + * @type {Object} + */ + @tracked errors = {}; + + /** + * The patches to be applied. + * @type {Array} + */ + patches = []; + + /** + * The inverse patches to be applied, useful for rollback. + * @type {Array} + */ + inversePatches = []; + + /** + * Creates an instance of Changeset. + * @param {any} data - The initial data. + */ + constructor(data) { + try { + this.data = produce(data, () => {}); + this.draftData = produce(data, () => {}); + } catch (e) { + if (e.message.includes("[Immer]")) { + throw new Error("[FormKit]: the @data property expects a POJO."); + } + } + } + + /** + * Checks if the changeset is valid. + * @return {boolean} True if there are no errors. + */ + get isValid() { + return Object.keys(this.errors).length === 0; + } + + /** + * Checks if the changeset is invalid. + * @return {boolean} True if there are errors. + */ + get isInvalid() { + return !this.isValid; + } + + /** + * Checks if the changeset is pristine. + * @return {boolean} True if no patches have been applied. + */ + get isPristine() { + return this.patches.length + this.inversePatches.length === 0; + } + + /** + * Checks if the changeset is dirty. + * @return {boolean} True if patches have been applied. + */ + get isDirty() { + return !this.isPristine; + } + + /** + * Executes the patches to update the data. + */ + execute() { + this.data = applyPatches(this.data, this.patches); + } + + /** + * Reverts the patches to update the data. + */ + unexecute() { + this.data = applyPatches(this.data, this.inversePatches); + } + + /** + * Saves the changes by executing the patches and resetting them. + */ + save() { + this.execute(); + this.resetPatches(); + } + + /** + * Rolls back all changes by applying the inverse patches. + * @return {Promise} A promise that resolves after the rollback is complete. + */ + async rollback() { + while (this.inversePatches.length > 0) { + this.draftData = applyPatches(this.draftData, [ + this.inversePatches.pop(), + ]); + } + + this.resetPatches(); + + await new Promise((resolve) => next(resolve)); + } + + /** + * Adds an error to a specific property. + * @param {string} name - The property name. + * @param {Object} error - The error to add. + * @param {string} error.title - The title of the error. + * @param {string} error.message - The message of the error. + */ + addError(name, error) { + if (this.errors.hasOwnProperty(name)) { + this.errors[name].messages.push(error.message); + this.errors = { ...this.errors }; + } else { + this.errors = { + ...this.errors, + [name]: { + title: error.title, + messages: [error.message], + }, + }; + } + } + + /** + * Removes an error from a specific property. + * @param {string} name - The property name. + */ + removeError(name) { + delete this.errors[name]; + this.errors = { ...this.errors }; + } + + /** + * Removes all errors from the changeset. + */ + removeErrors() { + this.errors = {}; + } + + /** + * Gets the value of a specific property from the draft data. + * @param {string} name - The property name. + * @return {any} The value of the property. + */ + get(name) { + const parts = name.split("."); + let target = this.draftData[parts.shift()]; + while (parts.length) { + target = target[parts.shift()]; + } + return target; + } + + /** + * Sets the value of a specific property in the draft data and tracks the changes. + * @param {string} name - The property name. + * @param {any} value - The value to set. + */ + set(name, value) { + this.draftData = produce( + this.draftData, + (target) => { + const parts = name.split("."); + while (parts.length > 1) { + target = target[parts.shift()]; + } + target[parts[0]] = value; + }, + (patches, inversePatches) => { + this.patches.push(...patches); + this.inversePatches.push(...inversePatches); + } + ); + } + + /** + * Resets the patches and inverse patches. + */ + resetPatches() { + this.patches = []; + this.inversePatches = []; + } +} diff --git a/app/assets/javascripts/discourse/app/form-kit/lib/validation-parser.js b/app/assets/javascripts/discourse/app/form-kit/lib/validation-parser.js new file mode 100644 index 00000000000..e62ff51f0ce --- /dev/null +++ b/app/assets/javascripts/discourse/app/form-kit/lib/validation-parser.js @@ -0,0 +1,65 @@ +export default class ValidationParser { + static parse(input) { + return new ValidationParser().parse(input); + } + + parse(input) { + const rules = {}; + (input?.split("|") ?? []).forEach((rule) => { + const [ruleName, args] = rule.split(":").filter(Boolean); + + if (this[ruleName + "Rule"]) { + rules[ruleName] = this[ruleName + "Rule"](args); + } else { + throw new Error(`Unknown rule: ${ruleName}`); + } + }); + + return rules; + } + + requiredRule(args = "") { + const [option] = args.split(","); + return { + trim: option === "trim", + }; + } + + urlRule() { + return {}; + } + + acceptedRule() { + return {}; + } + + numberRule() { + return {}; + } + + betweenRule(args) { + if (!args) { + throw new Error("`between` rule expects min/max, eg: between:1,10"); + } + + const [min, max] = args.split(",").map(Number); + + return { + min, + max, + }; + } + + lengthRule(args) { + if (!args) { + throw new Error("`length` rule expects min/max, eg: length:1,10"); + } + + const [min, max] = args.split(",").map(Number); + + return { + min, + max, + }; + } +} diff --git a/app/assets/javascripts/discourse/app/form-kit/lib/validator.js b/app/assets/javascripts/discourse/app/form-kit/lib/validator.js new file mode 100644 index 00000000000..c08d615da6a --- /dev/null +++ b/app/assets/javascripts/discourse/app/form-kit/lib/validator.js @@ -0,0 +1,120 @@ +import I18n from "discourse-i18n"; + +export default class Validator { + constructor(value, rules = {}) { + this.value = value; + this.rules = rules; + } + + async validate(type) { + const errors = []; + for (const rule in this.rules) { + if (this[rule + "Validator"]) { + const error = await this[rule + "Validator"]( + this.value, + this.rules[rule], + type + ); + + if (error) { + errors.push(error); + } + } else { + throw new Error(`Unknown validator: ${rule}`); + } + } + + return errors; + } + + lengthValidator(value, rule) { + if (rule.max) { + if (value?.length > rule.max) { + return I18n.t("form_kit.errors.too_long", { + count: rule.max, + }); + } + } + + if (rule.min) { + if (value?.length < rule.min) { + return I18n.t("form_kit.errors.too_short", { + count: rule.min, + }); + } + } + } + + betweenValidator(value, rule) { + if (rule.max) { + if (value > rule.max) { + return I18n.t("form_kit.errors.too_high", { + count: rule.max, + }); + } + } + + if (rule.min) { + if (value < rule.min) { + return I18n.t("form_kit.errors.too_low", { + count: rule.min, + }); + } + } + } + + numberValidator(value) { + if (isNaN(Number(value))) { + return I18n.t("form_kit.errors.not_a_number"); + } + } + + acceptedValidator(value) { + const acceptedValues = ["yes", "on", true, 1, "true"]; + if (!acceptedValues.includes(value)) { + return I18n.t("form_kit.errors.not_accepted"); + } + } + + urlValidator(value) { + try { + // eslint-disable-next-line no-new + new URL(value); + } catch (e) { + return I18n.t("form_kit.errors.invalid_url"); + } + } + + requiredValidator(value, rule, type) { + let error = false; + + switch (type) { + case "input-text": + if (rule.trim) { + value = value?.trim(); + } + if (!value || value === "") { + error = true; + } + break; + case "input-number": + if (typeof value === "undefined" || isNaN(Number(value))) { + error = true; + } + break; + case "question": + if (value !== false && !value) { + error = true; + } + break; + default: + if (!value) { + error = true; + } + } + + if (error) { + return I18n.t("form_kit.errors.required"); + } + } +} diff --git a/app/assets/javascripts/discourse/app/helpers/icon-or-image.js b/app/assets/javascripts/discourse/app/helpers/icon-or-image.js index 547ecbff865..cb3399834a4 100644 --- a/app/assets/javascripts/discourse/app/helpers/icon-or-image.js +++ b/app/assets/javascripts/discourse/app/helpers/icon-or-image.js @@ -1,8 +1,12 @@ +import { get } from "@ember/object"; import { htmlSafe } from "@ember/template"; import { isEmpty } from "@ember/utils"; import { convertIconClass, iconHTML } from "discourse-common/lib/icon-library"; -export default function iconOrImage({ icon, image }) { +export default function iconOrImage(badge) { + const icon = get(badge, "icon"); + const image = get(badge, "image"); + if (!isEmpty(image)) { return htmlSafe(``); } diff --git a/app/assets/javascripts/discourse/package.json b/app/assets/javascripts/discourse/package.json index 14a66293f03..6dc43e35abc 100644 --- a/app/assets/javascripts/discourse/package.json +++ b/app/assets/javascripts/discourse/package.json @@ -31,6 +31,7 @@ "ember-source": "~5.5.0", "handlebars": "^4.7.8", "highlight.js": "^11.10.0", + "immer": "^10.1.1", "jspreadsheet-ce": "^4.13.4", "morphlex": "^0.0.16", "pretty-text": "1.0.0" diff --git a/app/assets/javascripts/discourse/tests/acceptance/admin-badges-show-test.js b/app/assets/javascripts/discourse/tests/acceptance/admin-badges-show-test.js deleted file mode 100644 index 26348914723..00000000000 --- a/app/assets/javascripts/discourse/tests/acceptance/admin-badges-show-test.js +++ /dev/null @@ -1,137 +0,0 @@ -import { click, fillIn, visit } from "@ember/test-helpers"; -import { test } from "qunit"; -import { - acceptance, - exists, - query, -} from "discourse/tests/helpers/qunit-helpers"; - -acceptance("Admin - Badges - Show", function (needs) { - needs.user(); - needs.settings({ - enable_badge_sql: true, - }); - needs.pretender((server, helper) => { - server.post("/admin/badges/preview.json", () => - helper.response(200, { grant_count: 3, sample: [] }) - ); - }); - - test("new badge page", async function (assert) { - await visit("/admin/badges/new"); - assert.ok( - !query("input#badge-icon").checked, - "radio button for selecting an icon is off initially" - ); - assert.ok( - !query("input#badge-image").checked, - "radio button for uploading an image is off initially" - ); - assert.ok(!exists(".icon-picker"), "icon picker is not visible"); - assert.ok(!exists(".image-uploader"), "image uploader is not visible"); - - await click("input#badge-icon"); - assert.ok( - exists(".icon-picker"), - "icon picker is visible after clicking the select icon radio button" - ); - assert.ok(!exists(".image-uploader"), "image uploader remains hidden"); - - await click("input#badge-image"); - assert.ok( - !exists(".icon-picker"), - "icon picker is hidden after clicking the upload image radio button" - ); - assert.ok( - exists(".image-uploader"), - "image uploader becomes visible after clicking the upload image radio button" - ); - - assert.true( - exists("label[for=query]"), - "sql input is visible when enabled" - ); - - assert.false( - exists("input[name=auto_revoke]"), - "does not show sql-specific options when query is blank" - ); - - await fillIn(".ace-wrapper textarea", "SELECT 1"); - - assert.true( - exists("input[name=auto_revoke]"), - "shows sql-specific options when query is present" - ); - }); - - test("existing badge that has an icon", async function (assert) { - await visit("/admin/badges/1"); - assert.ok( - query("input#badge-icon").checked, - "radio button for selecting an icon is on" - ); - assert.ok( - !query("input#badge-image").checked, - "radio button for uploading an image is off" - ); - assert.ok(exists(".icon-picker"), "icon picker is visible"); - assert.ok(!exists(".image-uploader"), "image uploader is not visible"); - assert.strictEqual(query(".icon-picker").textContent.trim(), "fa-rocket"); - }); - - test("existing badge that has an image URL", async function (assert) { - await visit("/admin/badges/2"); - assert.ok( - !query("input#badge-icon").checked, - "radio button for selecting an icon is off" - ); - assert.ok( - query("input#badge-image").checked, - "radio button for uploading an image is on" - ); - assert.ok(!exists(".icon-picker"), "icon picker is not visible"); - assert.ok(exists(".image-uploader"), "image uploader is visible"); - assert.ok( - query(".image-uploader a.lightbox").href.endsWith("/images/avatar.png?2"), - "image uploader shows the right image" - ); - }); - - test("existing badge that has both an icon and image URL", async function (assert) { - await visit("/admin/badges/3"); - assert.ok( - !query("input#badge-icon").checked, - "radio button for selecting an icon is off because image overrides icon" - ); - assert.ok( - query("input#badge-image").checked, - "radio button for uploading an image is on because image overrides icon" - ); - assert.ok(!exists(".icon-picker"), "icon picker is not visible"); - assert.ok(exists(".image-uploader"), "image uploader is visible"); - assert.ok( - query(".image-uploader a.lightbox").href.endsWith("/images/avatar.png?3"), - "image uploader shows the right image" - ); - - await click("input#badge-icon"); - assert.ok(exists(".icon-picker"), "icon picker is becomes visible"); - assert.ok(!exists(".image-uploader"), "image uploader becomes hidden"); - assert.strictEqual(query(".icon-picker").textContent.trim(), "fa-rocket"); - }); - - test("sql input is hidden by default", async function (assert) { - this.siteSettings.enable_badge_sql = false; - await visit("/admin/badges/new"); - assert.dom("label[for=query]").doesNotExist(); - }); - - test("Badge preview displays the grant count", async function (assert) { - await visit("/admin/badges/3"); - await click("a.preview-badge"); - assert - .dom(".badge-query-preview .grant-count") - .hasText("3 badges to be assigned."); - }); -}); diff --git a/app/assets/javascripts/discourse/tests/helpers/form-kit-assertions.js b/app/assets/javascripts/discourse/tests/helpers/form-kit-assertions.js new file mode 100644 index 00000000000..9d5c973c988 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/helpers/form-kit-assertions.js @@ -0,0 +1,179 @@ +import { capitalize } from "@ember/string"; +import QUnit from "qunit"; +import { query } from "discourse/tests/helpers/qunit-helpers"; + +class FieldHelper { + constructor(element, context) { + this.element = element; + this.context = context; + } + + get value() { + switch (this.element.dataset.controlType) { + case "image": { + return this.element + .querySelector(".form-kit__control-image a.lightbox") + .getAttribute("href"); + } + case "radio-group": { + return this.element.querySelector(".form-kit__control-radio:checked") + ?.value; + } + case "password": + return this.element.querySelector(".form-kit__control-password").value; + case "input-number": + case "input-text": + return this.element.querySelector(".form-kit__control-input").value; + case "icon": { + return this.element.querySelector( + ".form-kit__control-icon .select-kit-header" + )?.dataset?.value; + } + case "question": { + return ( + this.element.querySelector(".form-kit__control-radio:checked") + ?.value === "true" + ); + } + case "toggle": { + return ( + this.element + .querySelector(".form-kit__control-toggle") + .getAttribute("aria-checked") === "true" + ); + } + case "textarea": { + return this.element.querySelector(".form-kit__control-textarea").value; + } + case "code": { + return this.element.querySelector( + ".form-kit__control-code .ace_text-input" + ).value; + } + case "composer": { + return this.element.querySelector( + ".form-kit__control-composer .d-editor-input" + ).value; + } + case "select": { + return this.element.querySelector(".form-kit__control-select").value; + } + case "menu": { + return this.element.querySelector(".form-kit__control-menu").dataset + .value; + } + case "checkbox": { + return this.element.querySelector(".form-kit__control-checkbox") + .checked; + } + } + } + + get isDisabled() { + return this.element.dataset.disabled === ""; + } + + hasCharCounter(current, max, message) { + this.context + .dom(this.element.querySelector(".form-kit__char-counter")) + .includesText(`${current}/${max}`, message); + } + + hasError(error, message) { + this.context + .dom(this.element.querySelector(".form-kit__errors")) + .includesText(error, message); + } + + hasNoError(message) { + this.context + .dom(this.element.querySelector(".form-kit__errors")) + .doesNotExist(message); + } + + doesNotExist(message) { + this.context.dom(this.element).doesNotExist(message); + } + + exists(message) { + this.context.dom(this.element).exists(message); + } +} + +class FormHelper { + constructor(selector, context) { + this.context = context; + if (selector instanceof HTMLElement) { + this.element = selector; + } else { + this.element = query(selector); + } + } + + hasErrors(fields, assertionMessage) { + const messages = Object.keys(fields).map((name) => { + return `${capitalize(name)}: ${fields[name]}`; + }); + + this.context + .dom(this.element.querySelector(".form-kit__errors-summary-list")) + .hasText(messages.join(" "), assertionMessage); + } + + hasNoErrors(message) { + this.context + .dom(this.element.querySelector(".form-kit__errors-summary-list")) + .doesNotExist(message); + } + + field(name) { + return new FieldHelper( + query(`.form-kit__field[data-name="${name}"]`, this.element), + this.context + ); + } +} + +export function setupFormKitAssertions() { + QUnit.assert.form = function (selector = "form") { + const form = new FormHelper(selector, this); + return { + hasErrors: (fields, message) => { + form.hasErrors(fields, message); + }, + hasNoErrors: (fields, message) => { + form.hasNoErrors(fields, message); + }, + field: (name) => { + const field = form.field(name); + + return { + doesNotExist: (message) => { + field.doesNotExist(message); + }, + exists: (message) => { + field.exists(message); + }, + isDisabled: (message) => { + this.ok(field.disabled, message); + }, + isEnabled: (message) => { + this.notOk(field.disabled, message); + }, + hasError: (message) => { + field.hasError(message); + }, + hasCharCounter: (current, max, message) => { + field.hasCharCounter(current, max, message); + }, + hasNoError: (message) => { + field.hasNoError(message); + }, + hasValue: (value, message) => { + this.deepEqual(field.value, value, message); + }, + }; + }, + }; + }; +} diff --git a/app/assets/javascripts/discourse/tests/helpers/form-kit-helper.js b/app/assets/javascripts/discourse/tests/helpers/form-kit-helper.js new file mode 100644 index 00000000000..662d069bf86 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/helpers/form-kit-helper.js @@ -0,0 +1,159 @@ +import { click, fillIn, triggerEvent } from "@ember/test-helpers"; +import { query } from "discourse/tests/helpers/qunit-helpers"; +import selectKit from "discourse/tests/helpers/select-kit-helper"; + +class Field { + constructor(selector) { + if (selector instanceof HTMLElement) { + this.element = selector; + } else { + this.element = query(selector); + } + } + + get controlType() { + return this.element.dataset.controlType; + } + + async fillIn(value) { + let element; + + switch (this.controlType) { + case "input-text": + case "input-number": + case "password": + element = this.element.querySelector("input"); + break; + case "code": + case "textarea": + case "composer": + element = this.element.querySelector("textarea"); + break; + default: + throw new Error(`Unsupported control type: ${this.controlType}`); + } + + await fillIn(element, value); + } + + async toggle() { + switch (this.controlType) { + case "password": + await click( + this.element.querySelector(".form-kit__control-password-toggle") + ); + break; + case "checkbox": + await click(this.element.querySelector("input")); + break; + case "toggle": + await click(this.element.querySelector("button")); + break; + default: + throw new Error(`Unsupported control type: ${this.controlType}`); + } + } + + async accept() { + if (this.controlType !== "question") { + throw new Error(`Unsupported control type: ${this.controlType}`); + } + + await click( + this.element.querySelector(".form-kit__control-radio[value='true']") + ); + } + + async refuse() { + if (this.controlType !== "question") { + throw new Error(`Unsupported control type: ${this.controlType}`); + } + + await click( + this.element.querySelector(".form-kit__control-radio[value='false']") + ); + } + + async select(value) { + switch (this.element.dataset.controlType) { + case "icon": + const picker = selectKit( + "#" + this.element.querySelector("details").id + ); + await picker.expand(); + await picker.selectRowByValue(value); + break; + case "select": + const select = this.element.querySelector("select"); + select.value = value; + await triggerEvent(select, "input"); + break; + case "menu": + const trigger = this.element.querySelector( + ".fk-d-menu__trigger.form-kit__control-menu" + ); + await click(trigger); + const menu = document.body.querySelector( + `[aria-labelledby="${trigger.id}"` + ); + const item = menu.querySelector( + `.form-kit__control-menu-item[data-value="${value}"] .btn` + ); + await click(item); + break; + case "radio-group": + const radio = this.element.querySelector( + `input[type="radio"][value="${value}"]` + ); + await click(radio); + break; + default: + throw new Error("Unsupported field type"); + } + } +} + +class Form { + constructor(selector) { + if (selector instanceof HTMLElement) { + this.element = selector; + } else { + this.element = query(selector); + } + } + + async submit() { + await triggerEvent(this.element, "submit"); + } + + async reset() { + await triggerEvent(this.element, "reset"); + } + + field(name) { + const field = new Field( + this.element.querySelector(`[data-name="${name}"]`) + ); + + if (!field) { + throw new Error(`Field with name ${name} not found`); + } + + return field; + } +} +export default function form(selector = "form") { + const helper = new Form(selector); + + return { + async submit() { + await helper.submit(); + }, + async reset() { + await helper.reset(); + }, + field(name) { + return helper.field(name); + }, + }; +} diff --git a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js index cd9936d6231..e51d2af4401 100644 --- a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js +++ b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js @@ -101,6 +101,7 @@ import { cloneJSON, deepMerge } from "discourse-common/lib/object"; import { clearResolverOptions } from "discourse-common/resolver"; import I18n from "discourse-i18n"; import { _clearSnapshots } from "select-kit/components/composer-actions"; +import { setupFormKitAssertions } from "./form-kit-assertions"; import { cleanupTemporaryModuleRegistrations } from "./temporary-module-helper"; export function currentUser() { @@ -505,6 +506,8 @@ QUnit.assert.containsInstance = function (collection, klass, message) { }); }; +setupFormKitAssertions(); + export async function selectDate(selector, date) { const elem = document.querySelector(selector); elem.value = date; diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/collection-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/collection-test.gjs new file mode 100644 index 00000000000..87ad6ca58e8 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/collection-test.gjs @@ -0,0 +1,48 @@ +import { array, concat, fn, hash } from "@ember/helper"; +import { click, render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; + +module("Integration | Component | FormKit | Collection", function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + await render(); + + assert.form().field("foo.0.bar").hasValue("1"); + assert.form().field("foo.1.bar").hasValue("2"); + }); + + test("remove", async function (assert) { + await render(); + + assert.form().field("foo.0.bar").hasValue("1"); + assert.form().field("foo.1.bar").hasValue("2"); + + await click(".remove-1"); + + assert.form().field("foo.0.bar").hasValue("1"); + assert.form().field("foo.1.bar").doesNotExist(); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/char-counter-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/char-counter-test.gjs new file mode 100644 index 00000000000..c74433a262f --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/char-counter-test.gjs @@ -0,0 +1,36 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import formKit from "discourse/tests/helpers/form-kit-helper"; + +module( + "Integration | Component | FormKit | Controls | CharCounter", + function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + let data = { foo: null }; + const mutateData = (x) => (data = x); + + await render(); + + assert.form().field("foo").hasCharCounter(0, 5); + + await formKit().field("foo").fillIn("foo"); + + assert.form().field("foo").hasCharCounter(3, 5); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/checkbox-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/checkbox-test.gjs new file mode 100644 index 00000000000..7fa9581f4bd --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/checkbox-test.gjs @@ -0,0 +1,36 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import formKit from "discourse/tests/helpers/form-kit-helper"; + +module( + "Integration | Component | FormKit | Controls | Checkbox", + function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + let data = { foo: null }; + const mutateData = (x) => (data = x); + + await render(); + + assert.deepEqual(data, { foo: null }); + assert.form().field("foo").hasValue(false); + + await formKit().field("foo").toggle(); + + assert.form().field("foo").hasValue(true); + + await formKit().submit(); + + assert.deepEqual(data, { foo: true }); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/code-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/code-test.gjs new file mode 100644 index 00000000000..0032105c2e3 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/code-test.gjs @@ -0,0 +1,31 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import formKit from "discourse/tests/helpers/form-kit-helper"; + +module("Integration | Component | FormKit | Controls | Code", function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + let data = { foo: null }; + const mutateData = (x) => (data = x); + + await render(); + + assert.deepEqual(data, { foo: null }); + assert.form().field("foo").hasValue(""); + + await formKit().field("foo").fillIn("bar"); + await formKit().submit(); + + assert.deepEqual(data, { foo: "bar" }); + assert.form().field("foo").hasValue("bar"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/composer-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/composer-test.gjs new file mode 100644 index 00000000000..9ca43f9a40c --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/composer-test.gjs @@ -0,0 +1,36 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import formKit from "discourse/tests/helpers/form-kit-helper"; + +module( + "Integration | Component | FormKit | Controls | Composer", + function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + let data = { foo: null }; + const mutateData = (x) => (data = x); + + await render(); + + assert.deepEqual(data, { foo: null }); + assert.form().field("foo").hasValue(""); + + await formKit().field("foo").fillIn("bar"); + + assert.form().field("foo").hasValue("bar"); + + await formKit().submit(); + + assert.deepEqual(data, { foo: "bar" }); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/icon-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/icon-test.gjs new file mode 100644 index 00000000000..0d7b51ce7f4 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/icon-test.gjs @@ -0,0 +1,35 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import pretender, { response } from "discourse/tests/helpers/create-pretender"; +import formKit from "discourse/tests/helpers/form-kit-helper"; + +module("Integration | Component | FormKit | Controls | Icon", function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + pretender.get("/svg-sprite/picker-search", () => + response(200, [{ id: "pencil-alt", name: "pencil-alt" }]) + ); + }); + + test("default", async function (assert) { + let data = { foo: null }; + const mutateData = (x) => (data = x); + + await render(); + + await formKit().field("foo").select("pencil-alt"); + await formKit().submit(); + + assert.deepEqual(data.foo, "pencil-alt"); + assert.form().field("foo").hasValue("pencil-alt"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/image-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/image-test.gjs new file mode 100644 index 00000000000..1b37678edde --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/image-test.gjs @@ -0,0 +1,49 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import pretender, { response } from "discourse/tests/helpers/create-pretender"; +import formKit from "discourse/tests/helpers/form-kit-helper"; + +module( + "Integration | Component | FormKit | Controls | Image", + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + pretender.post("/uploads.json", () => + response({ + extension: "jpeg", + filesize: 126177, + height: 800, + human_filesize: "123 KB", + id: 202, + original_filename: "avatar.PNG.jpg", + retain_hours: null, + short_path: "/uploads/short-url/yoj8pf9DdIeHRRULyw7i57GAYdz.jpeg", + short_url: "upload://yoj8pf9DdIeHRRULyw7i57GAYdz.jpeg", + thumbnail_height: 320, + thumbnail_width: 690, + url: "/images/discourse-logo-sketch-small.png", + width: 1920, + }) + ); + }); + + test("default", async function (assert) { + let data = { image_url: "/images/discourse-logo-sketch-small.png" }; + + await render(); + + await formKit().submit(); + + assert.form().field("image_url").hasValue(data.image_url); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/input-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/input-test.gjs new file mode 100644 index 00000000000..e6c7367430a --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/input-test.gjs @@ -0,0 +1,58 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import formKit from "discourse/tests/helpers/form-kit-helper"; + +module( + "Integration | Component | FormKit | Controls | Input", + function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + let data = { foo: "" }; + const mutateData = (x) => (data = x); + + await render(); + + assert.form().field("foo").hasValue(""); + + await formKit().field("foo").fillIn("bar"); + + assert.form().field("foo").hasValue("bar"); + + await formKit().submit(); + + assert.deepEqual(data.foo, "bar"); + }); + + test("@type", async function (assert) { + let data = { foo: "" }; + const mutateData = (x) => (data = x); + + await render(); + + assert.form().field("foo").hasValue(""); + + await formKit().field("foo").fillIn(1); + + assert.form().field("foo").hasValue("1"); + + await formKit().submit(); + + assert.deepEqual(data.foo, 1); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/menu-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/menu-test.gjs new file mode 100644 index 00000000000..177cbcd56ae --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/menu-test.gjs @@ -0,0 +1,35 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import formKit from "discourse/tests/helpers/form-kit-helper"; + +module("Integration | Component | FormKit | Controls | Menu", function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + let data = { foo: "item-2" }; + const mutateData = (x) => (data = x); + + await render(); + + assert.deepEqual(data, { foo: "item-2" }); + assert.form().field("foo").hasValue("item-2"); + + await formKit().field("foo").select("item-3"); + await formKit().submit(); + + assert.deepEqual(data, { foo: "item-3" }); + assert.form().field("foo").hasValue("item-3"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/password-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/password-test.gjs new file mode 100644 index 00000000000..ba9800bc3fd --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/password-test.gjs @@ -0,0 +1,57 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import formKit from "discourse/tests/helpers/form-kit-helper"; + +module( + "Integration | Component | FormKit | Controls | Password", + function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + let data = { foo: "" }; + const mutateData = (x) => (data = x); + + await render(); + + assert.form().field("foo").hasValue(""); + + await formKit().field("foo").fillIn("bar"); + + assert.form().field("foo").hasValue("bar"); + + await formKit().submit(); + + assert.deepEqual(data.foo, "bar"); + }); + + test("toggle visibility", async function (assert) { + let data = { foo: "test" }; + + await render(); + + assert + .dom(formKit().field("foo").element.querySelector("input")) + .hasAttribute("type", "password"); + + await formKit().field("foo").toggle(); + + assert + .dom(formKit().field("foo").element.querySelector("input")) + .hasAttribute("type", "text"); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/question-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/question-test.gjs new file mode 100644 index 00000000000..a0d01c1b86c --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/question-test.gjs @@ -0,0 +1,60 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import formKit from "discourse/tests/helpers/form-kit-helper"; + +module( + "Integration | Component | FormKit | Controls | Question", + function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + let data = { foo: null }; + const mutateData = (x) => (data = x); + + await render(); + + assert.deepEqual(data, { foo: null }); + assert.form().field("foo").hasValue(false); + + await formKit().field("foo").accept(); + + assert.form().field("foo").hasValue(true); + + await formKit().submit(); + + assert.deepEqual(data, { foo: true }); + }); + + test("@yesLabel", async function (assert) { + await render(); + + assert.dom(".form-kit__control-radio-label.--yes").hasText("Correct"); + }); + + test("@noLabel", async function (assert) { + await render(); + + assert.dom(".form-kit__control-radio-label.--no").hasText("Wrong"); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/radio-group-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/radio-group-test.gjs new file mode 100644 index 00000000000..1cdccbf422c --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/radio-group-test.gjs @@ -0,0 +1,39 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import formKit from "discourse/tests/helpers/form-kit-helper"; + +module( + "Integration | Component | FormKit | Controls | RadioGroup", + function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + let data = { foo: "one" }; + const mutateData = (x) => (data = x); + + await render(); + + assert.form().field("foo").hasValue("one"); + + await formKit().field("foo").select("two"); + + assert.form().field("foo").hasValue("two"); + + await formKit().submit(); + + assert.deepEqual(data.foo, "two"); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/select-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/select-test.gjs new file mode 100644 index 00000000000..2bbb4198eba --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/select-test.gjs @@ -0,0 +1,40 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import formKit from "discourse/tests/helpers/form-kit-helper"; + +module( + "Integration | Component | FormKit | Controls | Select", + function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + let data = { foo: "option-2" }; + const mutateData = (x) => (data = x); + + await render(); + + assert.deepEqual(data, { foo: "option-2" }); + assert.form().field("foo").hasValue("option-2"); + + await formKit().field("foo").select("option-3"); + + assert.form().field("foo").hasValue("option-3"); + + await formKit().submit(); + + assert.deepEqual(data, { foo: "option-3" }); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/textarea-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/textarea-test.gjs new file mode 100644 index 00000000000..d6192c65f65 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/textarea-test.gjs @@ -0,0 +1,36 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import formKit from "discourse/tests/helpers/form-kit-helper"; + +module( + "Integration | Component | FormKit | Controls | Textarea", + function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + let data = { foo: null }; + const mutateData = (x) => (data = x); + + await render(); + + assert.deepEqual(data, { foo: null }); + assert.form().field("foo").hasValue(""); + + await formKit().field("foo").fillIn("bar"); + + assert.form().field("foo").hasValue("bar"); + + await formKit().submit(); + + assert.deepEqual(data, { foo: "bar" }); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/toggle-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/toggle-test.gjs new file mode 100644 index 00000000000..8740c9de975 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/controls/toggle-test.gjs @@ -0,0 +1,36 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import formKit from "discourse/tests/helpers/form-kit-helper"; + +module( + "Integration | Component | FormKit | Controls | Toggle", + function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + let data = { foo: null }; + const mutateData = (x) => (data = x); + + await render(); + + assert.deepEqual(data, { foo: null }); + assert.form().field("foo").hasValue(false); + + await formKit().field("foo").toggle(); + + assert.form().field("foo").hasValue(true); + + await formKit().submit(); + + assert.deepEqual(data, { foo: true }); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/field-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/field-test.gjs new file mode 100644 index 00000000000..d84b5f26c49 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/field-test.gjs @@ -0,0 +1,161 @@ +import { hash } from "@ember/helper"; +import { + fillIn, + render, + resetOnerror, + settled, + setupOnerror, +} from "@ember/test-helpers"; +import { module, test } from "qunit"; +import sinon from "sinon"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import formKit from "discourse/tests/helpers/form-kit-helper"; + +module("Integration | Component | FormKit | Field", function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.consoleWarnStub = sinon.stub(console, "error"); + }); + + hooks.afterEach(function () { + this.consoleWarnStub.restore(); + }); + + test("@size", async function (assert) { + await render(); + + assert.dom(".form-kit__row .form-kit__col.--col-8").hasText("Test"); + }); + + test("invalid @name", async function (assert) { + setupOnerror((error) => { + assert.deepEqual(error.message, "@name can't include `.` or `-`."); + }); + + await render(); + + resetOnerror(); + }); + + test("non existing title", async function (assert) { + setupOnerror((error) => { + assert.deepEqual( + error.message, + "@title is required on ``." + ); + }); + + await render(); + + resetOnerror(); + }); + + test("@validation", async function (assert) { + await render(); + + await formKit().submit(); + + assert.form().hasErrors({ foo: ["Required"], bar: ["Required"] }); + assert.form().field("foo").hasError("Required"); + assert.form().field("bar").hasError("Required"); + }); + + test("@validate", async function (assert) { + const validate = async (name, value, { addError, data }) => { + assert.deepEqual(name, "foo", "the callback has the name as param"); + assert.deepEqual(value, "bar", "the callback has the name as param"); + assert.deepEqual( + data, + { foo: "bar" }, + "the callback has the data as param" + ); + + addError("foo", { title: "Some error", message: "error" }); + }; + + await render(); + + await formKit().submit(); + + assert + .form() + .field("foo") + .hasError("error", "the callback has the addError helper as param"); + }); + + test("@showTitle", async function (assert) { + await render(); + + assert.dom(".form-kit__container-title").doesNotExist(); + }); + + test("@onSet", async function (assert) { + const onSetWasCalled = assert.async(); + + const onSet = async (value, { set }) => { + assert.form().field("foo").hasValue("bar"); + + await set("foo", "baz"); + await settled(); + + assert.form().field("foo").hasValue("baz"); + onSetWasCalled(); + }; + + await render(); + + await fillIn("input", "bar"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/form-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/form-test.gjs new file mode 100644 index 00000000000..564319df550 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/form-test.gjs @@ -0,0 +1,216 @@ +import { array, fn, hash } from "@ember/helper"; +import { click, render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import formKit from "discourse/tests/helpers/form-kit-helper"; + +module("Integration | Component | FormKit | Form", function (hooks) { + setupRenderingTest(hooks); + + test("@onSubmit", async function (assert) { + const onSubmit = (data) => { + assert.deepEqual(data.foo, 1); + }; + + await render(); + + await formKit().submit(); + }); + + test("addItemToCollection", async function (assert) { + await render(); + + await click("button"); + + assert.form().field("foo.0.bar").hasValue("1"); + assert.form().field("foo.1.bar").hasValue("2"); + assert.form().field("foo.2.bar").hasValue("3"); + }); + + test("@validate", async function (assert) { + const validate = async (data, { addError }) => { + assert.deepEqual(data.foo, 1); + assert.deepEqual(data.bar, 2); + addError("foo", { title: "Foo", message: "incorrect type" }); + addError("foo", { title: "Foo", message: "required" }); + addError("bar", { title: "Bar", message: "error" }); + }; + + await render(); + + await formKit().submit(); + + assert.form().hasErrors({ + foo: "incorrect type, required", + bar: "error", + }); + }); + + test("@validateOn", async function (assert) { + const data = { foo: "test" }; + + await render(); + + await formKit().field("foo").fillIn(""); + + assert.form().field("foo").hasNoError(); + + await formKit().submit(); + + assert.form().field("foo").hasError("Required"); + assert.form().field("bar").hasError("Required"); + assert.form().hasErrors({ + foo: "Required", + bar: "Required", + }); + + await formKit().field("foo").fillIn("t"); + + assert.form().field("foo").hasNoError(); + assert.form().field("bar").hasError("Required"); + assert.form().hasErrors({ + bar: "Required", + }); + }); + + test("@onRegisterApi", async function (assert) { + let formApi; + let model = { foo: 1 }; + + const registerApi = (api) => { + formApi = api; + }; + + const submit = (x) => { + model = x; + assert.deepEqual(model.foo, 1); + }; + + await render(); + + await formApi.set("bar", 2); + await formApi.submit(); + + assert.dom(".bar").hasText("2"); + + await formApi.set("bar", 1); + await formApi.reset(); + await formApi.submit(); + + assert.dom(".bar").hasText("2"); + }); + + test("@data", async function (assert) { + await render(); + + assert.dom(".foo").hasText("1"); + }); + + test("@onReset", async function (assert) { + const done = assert.async(); + const onReset = async () => { + assert + .form() + .field("bar") + .hasValue("1", "it resets the data to its initial state"); + done(); + }; + + await render(); + + await click(".set-bar"); + await formKit().field("foo").fillIn(""); + + await formKit().submit(); + + assert.form().field("bar").hasValue("2"); + assert.form().field("foo").hasError("Required"); + + await formKit().reset(); + + assert.form().field("foo").hasNoError("it resets the errors"); + }); + + test("immutable by default", async function (assert) { + const data = { foo: 1 }; + + await render(); + + await click(".set-foo"); + + assert.deepEqual(data.foo, 1); + }); + + test("yielded set", async function (assert) { + await render(); + + await click(".test"); + + assert.dom(".foo").hasText("2"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/input-group-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/input-group-test.gjs new file mode 100644 index 00000000000..4d8d0429b8a --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/input-group-test.gjs @@ -0,0 +1,44 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import formKit from "discourse/tests/helpers/form-kit-helper"; + +module( + "Integration | Component | FormKit | Controls | InputGroup", + function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + let data = {}; + const mutateData = (x) => (data = x); + + await render(); + + assert.form().field("foo").hasValue(""); + assert.form().field("bar").hasValue(""); + assert.deepEqual(data, {}); + + await formKit().field("foo").fillIn("foobar"); + await formKit().field("bar").fillIn("barbaz"); + + assert.form().field("foo").hasValue("foobar"); + assert.form().field("bar").hasValue("barbaz"); + + await formKit().submit(); + + assert.deepEqual(data, { foo: "foobar", bar: "barbaz" }); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/actions-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/actions-test.gjs new file mode 100644 index 00000000000..b28a74905f7 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/actions-test.gjs @@ -0,0 +1,23 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; + +module( + "Integration | Component | FormKit | Layout | Actions", + function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + await render(); + + assert + .dom(".form-kit__section.form-kit__actions.something") + .hasText("Test"); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/alert-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/alert-test.gjs new file mode 100644 index 00000000000..0a97459820e --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/alert-test.gjs @@ -0,0 +1,43 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; + +module("Integration | Component | FormKit | Layout | Alert", function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + await render(); + + assert.dom(".form-kit__alert-message").hasText("Test"); + }); + + test("@icon", async function (assert) { + await render(); + + assert.dom(".form-kit__alert .d-icon-pencil-alt").exists(); + }); + + test("@type", async function (assert) { + const types = ["success", "error", "warning", "info"]; + for (let i = 0, length = types.length; i < length; i++) { + const type = types[i]; + + await render(); + + assert.dom(`.form-kit__alert.alert.alert-${type}`).exists(); + } + }); +}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/button-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/button-test.gjs new file mode 100644 index 00000000000..ece2ddd9cdc --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/button-test.gjs @@ -0,0 +1,25 @@ +import { fn } from "@ember/helper"; +import { click, render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; + +module("Integration | Component | FormKit | Layout | Button", function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + const done = assert.async(); + const somethingAction = (value) => { + assert.deepEqual(value, 1); + done(); + }; + + await render(); + + await click(".something"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/container-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/container-test.gjs new file mode 100644 index 00000000000..b288072832a --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/container-test.gjs @@ -0,0 +1,47 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; + +module( + "Integration | Component | FormKit | Layout | Container", + function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + await render(); + + assert + .dom(".form-kit__container.something .form-kit__container-content") + .hasText("Test"); + }); + + test("@title", async function (assert) { + await render(); + + assert + .dom(".form-kit__container .form-kit__container-title") + .hasText("Title"); + }); + + test("@subtitle", async function (assert) { + await render(); + + assert + .dom(".form-kit__container .form-kit__container-subtitle") + .hasText("Subtitle"); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/row-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/row-test.gjs new file mode 100644 index 00000000000..40e8ea708c2 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/row-test.gjs @@ -0,0 +1,32 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; + +module("Integration | Component | FormKit | Layout | Row", function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + await render(); + + assert.dom(".form-kit__row .form-kit__col").hasText("Test"); + }); + + test("@size", async function (assert) { + await render(); + + assert.dom(".form-kit__row .form-kit__col.--col-6").hasText("Test"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/section-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/section-test.gjs new file mode 100644 index 00000000000..0db4759bf95 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/section-test.gjs @@ -0,0 +1,45 @@ +import { render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; + +module( + "Integration | Component | FormKit | Layout | Section", + function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + await render(); + + assert.dom(".form-kit__section.something").hasText("Test"); + }); + + test("@title", async function (assert) { + await render(); + + assert + .dom(".form-kit__section .form-kit__section-title") + .hasText("Title"); + }); + + test("@subtitle", async function (assert) { + await render(); + + assert + .dom(".form-kit__section .form-kit__section-subtitle") + .hasText("Subtitle"); + }); + } +); diff --git a/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/submit-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/submit-test.gjs new file mode 100644 index 00000000000..6c8c2b312aa --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/form-kit/layout/submit-test.gjs @@ -0,0 +1,29 @@ +import { click, render } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Form from "discourse/components/form"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import I18n from "I18n"; + +module("Integration | Component | FormKit | Layout | Submit", function (hooks) { + setupRenderingTest(hooks); + + test("default", async function (assert) { + let value; + const done = assert.async(); + const submit = () => { + value = 1; + done(); + }; + + await render(); + + await click("button"); + + assert.dom(".form-kit__button.btn-primary").hasText(I18n.t("submit")); + assert.deepEqual(value, 1); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/form-kit/validation-parser-test.js b/app/assets/javascripts/discourse/tests/unit/lib/form-kit/validation-parser-test.js new file mode 100644 index 00000000000..27e5e3d5814 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/form-kit/validation-parser-test.js @@ -0,0 +1,53 @@ +import { setupTest } from "ember-qunit"; +import { module, test } from "qunit"; +import ValidationParser from "discourse/form-kit/lib/validation-parser"; + +module("Unit | Lib | FormKit | ValidationParser", function (hooks) { + setupTest(hooks); + + test("combining rules", function (assert) { + const rules = ValidationParser.parse("required|url"); + + assert.deepEqual(rules.required, { trim: false }); + assert.deepEqual(rules.url, {}); + }); + + test("unknown rule", function (assert) { + assert.throws(() => ValidationParser.parse("foo"), "Unknown rule: foo"); + }); + + test("required", function (assert) { + const rules = ValidationParser.parse("required"); + + assert.deepEqual(rules.required, { trim: false }); + }); + + test("url", function (assert) { + const rules = ValidationParser.parse("url"); + + assert.deepEqual(rules.url, {}); + }); + + test("accepted", function (assert) { + const rules = ValidationParser.parse("accepted"); + + assert.deepEqual(rules.accepted, {}); + }); + + test("number", function (assert) { + const rules = ValidationParser.parse("number"); + + assert.deepEqual(rules.number, {}); + }); + + test("length", function (assert) { + assert.throws( + () => ValidationParser.parse("length"), + "`length` rule expects min/max, eg: length:1,10" + ); + + const rules = ValidationParser.parse("length:1,10"); + + assert.deepEqual(rules.length, { min: 1, max: 10 }); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/form-kit/validator-test.js b/app/assets/javascripts/discourse/tests/unit/lib/form-kit/validator-test.js new file mode 100644 index 00000000000..304bd9c14ff --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/form-kit/validator-test.js @@ -0,0 +1,252 @@ +import { setupTest } from "ember-qunit"; +import { module, test } from "qunit"; +import Validator from "discourse/form-kit/lib/validator"; +import I18n from "I18n"; + +module("Unit | Lib | FormKit | Validator", function (hooks) { + setupTest(hooks); + + test("unknown validator", async function (assert) { + const validator = await new Validator(1, { foo: {} }); + + try { + await validator.validate(); + } catch (e) { + assert.deepEqual(e.message, "Unknown validator: foo"); + } + }); + + test("length", async function (assert) { + let errors = await new Validator("", { + length: { min: 1, max: 5 }, + }).validate(); + + assert.deepEqual( + errors, + [ + I18n.t("form_kit.errors.too_short", { + count: 1, + }), + ], + "it returns an error when the value is too short" + ); + + errors = await new Validator("aaaaaa", { + length: { min: 1, max: 5 }, + }).validate(); + assert.deepEqual( + errors, + [ + I18n.t("form_kit.errors.too_long", { + count: 5, + }), + ], + "it returns an error when the value is too long" + ); + + errors = await new Validator("aaa", { + length: { min: 1, max: 5 }, + }).validate(); + assert.deepEqual( + errors, + [], + "it returns no errors when the value is valid" + ); + }); + + test("between", async function (assert) { + let errors = await new Validator(0, { + between: { min: 1, max: 5 }, + }).validate(); + + assert.deepEqual( + errors, + [ + I18n.t("form_kit.errors.too_low", { + count: 1, + }), + ], + "it returns an error when the value is too low" + ); + + errors = await new Validator(6, { + between: { min: 1, max: 5 }, + }).validate(); + assert.deepEqual( + errors, + [ + I18n.t("form_kit.errors.too_high", { + count: 5, + }), + ], + "it returns an error when the value is too high" + ); + + errors = await new Validator(5, { + between: { min: 1, max: 5 }, + }).validate(); + assert.deepEqual( + errors, + [], + "it returns no errors when the value is valid" + ); + }); + + test("number", async function (assert) { + let errors = await new Validator("A", { + number: {}, + }).validate(); + + assert.deepEqual( + errors, + [I18n.t("form_kit.errors.not_a_number")], + "it returns an error when the value is not a number" + ); + + errors = await new Validator(1, { number: {} }).validate(); + assert.deepEqual( + errors, + [], + "it returns no errors when the value is a number" + ); + }); + + test("url", async function (assert) { + let errors = await new Validator("A", { + url: {}, + }).validate(); + + assert.deepEqual( + errors, + [I18n.t("form_kit.errors.invalid_url")], + "it returns an error when the value is not a valid URL" + ); + + errors = await new Validator("http://www.discourse.org", { + url: {}, + }).validate(); + assert.deepEqual( + errors, + [], + "it returns no errors when the value is a valid URL" + ); + }); + + test("accepted", async function (assert) { + let errors = await new Validator("A", { + accepted: {}, + }).validate(); + assert.deepEqual( + errors, + [I18n.t("form_kit.errors.not_accepted")], + "it returns an error when the value is not accepted" + ); + + errors = await new Validator(1, { accepted: {} }).validate(); + assert.deepEqual( + errors, + [], + "it returns no errors when the value is truthy" + ); + + errors = await new Validator(true, { accepted: {} }).validate(); + assert.deepEqual( + errors, + [], + "it returns no errors when the value is truthy" + ); + + errors = await new Validator("true", { accepted: {} }).validate(); + assert.deepEqual( + errors, + [], + "it returns no errors when the value is truthy" + ); + + errors = await new Validator("on", { accepted: {} }).validate(); + assert.deepEqual( + errors, + [], + "it returns no errors when the value is truthy" + ); + + errors = await new Validator("yes", { accepted: {} }).validate(); + assert.deepEqual( + errors, + [], + "it returns no errors when the value is truthy" + ); + }); + + test("required", async function (assert) { + let errors = await new Validator(" ", { + required: { trim: true }, + }).validate("input-text"); + + assert.deepEqual( + errors, + [I18n.t("form_kit.errors.required")], + "it returns an error when the value is empty spaces with trim" + ); + + errors = await new Validator(" ", { + required: { trim: false }, + }).validate("input-text"); + + assert.deepEqual( + errors, + [], + "it returns no errors when the value is empty spaces without trim" + ); + + errors = await new Validator(undefined, { + required: {}, + }).validate("input-number"); + assert.deepEqual( + errors, + [I18n.t("form_kit.errors.required")], + "it returns an error when the value is undefined" + ); + + errors = await new Validator("A", { + required: {}, + }).validate("input-number"); + assert.deepEqual( + errors, + [I18n.t("form_kit.errors.required")], + "it returns an error when the value is not a number" + ); + + errors = await new Validator(false, { + required: {}, + }).validate("question"); + assert.deepEqual( + errors, + [], + "it returns no errors when the value is false" + ); + + errors = await new Validator(true, { + required: {}, + }).validate("question"); + assert.deepEqual(errors, [], "it returns no errors when the value is true"); + + errors = await new Validator(undefined, { + required: {}, + }).validate("question"); + assert.deepEqual( + errors, + [I18n.t("form_kit.errors.required")], + "it returns an error when the value is undefined" + ); + + errors = await new Validator(undefined, { + required: {}, + }).validate("menu"); + assert.deepEqual( + errors, + [I18n.t("form_kit.errors.required")], + "it returns an error when the value is undefined" + ); + }); +}); diff --git a/app/assets/javascripts/float-kit/addon/lib/constants.js b/app/assets/javascripts/float-kit/addon/lib/constants.js index 0d13ee40ce5..ed7db412926 100644 --- a/app/assets/javascripts/float-kit/addon/lib/constants.js +++ b/app/assets/javascripts/float-kit/addon/lib/constants.js @@ -61,7 +61,7 @@ export const MENU = { offset: 10, triggers: ["click"], untriggers: ["click"], - placement: "bottom", + placement: "bottom-start", fallbackPlacements: FLOAT_UI_PLACEMENTS, autoUpdate: true, trapTab: true, diff --git a/app/assets/stylesheets/common.scss b/app/assets/stylesheets/common.scss index 6b7590894b8..57e76d81116 100644 --- a/app/assets/stylesheets/common.scss +++ b/app/assets/stylesheets/common.scss @@ -21,3 +21,4 @@ @import "common/login/_index"; @import "common/table-builder/_index"; @import "common/post-action-feedback"; +@import "common/form-kit/_index"; diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index 10638de9170..ec4001172ef 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -341,7 +341,6 @@ $mobile-breakpoint: 700px; } .admin-content { - margin-bottom: 50px; .admin-contents { padding: 0 0 8px 0; @include clearfix(); diff --git a/app/assets/stylesheets/common/admin/badges.scss b/app/assets/stylesheets/common/admin/badges.scss index 2af146bb6a1..7e6dbf12400 100644 --- a/app/assets/stylesheets/common/admin/badges.scss +++ b/app/assets/stylesheets/common/admin/badges.scss @@ -1,6 +1,7 @@ // Styles for admin/badges .admin-badges { // flex page layout + .badges { display: flex; flex-wrap: wrap; @@ -10,6 +11,9 @@ flex: 1 0 100%; .create-new-badge { margin-left: auto; + display: flex; + align-items: center; + gap: 0.5em; } } .content-list { @@ -60,67 +64,48 @@ background-color: unset; } } + + .current-badge-header { + display: flex; + gap: 1em; + align-items: center; + font-size: var(--font-up-2-rem); + + img { + border-radius: var(--d-border-radius-large); + max-width: 36px; + } + + .d-icon { + font-size: 36px; + } + + .badge-display-name { + font-size: var(--font-up-1); + font-weight: bold; + word-break: break-word; + } + } + .current-badge { margin: 20px; - p.help { - margin: 0; - margin-top: 5px; - color: var(--primary-medium); - font-size: var(--font-down-1); - } - .badge-grouping-control { - display: flex; - align-items: center; - .badge-selector { - margin-right: 5px; + + .form-kit__field-question { + .form-kit__control-radio-label { + text-transform: capitalize; } } - .icon-picker { - width: 350px; - } - } - .current-badge { - .ace-wrapper { - position: relative; - height: 270px; - .ace_editor { - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - } - &[data-disabled="true"] { - cursor: not-allowed; - opacity: 0.5; - .ace_editor { - pointer-events: none; - .ace_cursor { - visibility: hidden; - } - } - } - } - textarea { - height: 200px; + .readonly-field { + color: var(--primary-high); } } + .current-badge-actions { margin: 10px; padding: 10px; border-top: 1px solid var(--primary-low); } - .buttons { - display: flex; - align-items: center; - button { - margin-right: 0.5em; - } - .saving { - order: 3; - } - } } .award-badge { @@ -188,10 +173,15 @@ // badge preview modal .badge-query-preview { + .badge-query-plan { + overflow-x: auto; + } + .badge-errors, .badge-query-plan { padding: 5px; background-color: var(--primary-low); + white-space: pre-wrap; } .count-warning { background-color: var(--danger-low); diff --git a/app/assets/stylesheets/common/admin/flags.scss b/app/assets/stylesheets/common/admin/flags.scss index 3d7f157d478..9a35c11e5bc 100644 --- a/app/assets/stylesheets/common/admin/flags.scss +++ b/app/assets/stylesheets/common/admin/flags.scss @@ -12,7 +12,7 @@ align-items: center; justify-content: space-between; } - .d-toggle-switch--label { + .d-toggle-switch__label { margin-bottom: 0; } .d-toggle-switch { diff --git a/app/assets/stylesheets/common/base/discourse.scss b/app/assets/stylesheets/common/base/discourse.scss index 14f170846f2..5116f759ec8 100644 --- a/app/assets/stylesheets/common/base/discourse.scss +++ b/app/assets/stylesheets/common/base/discourse.scss @@ -7,8 +7,8 @@ --d-input-bg-color: var(--secondary); --d-input-text-color: var(--primary); --d-input-border: 1px solid var(--primary-400); - --d-input-bg-color--disabled: var(--primary-low); - --d-input-text-color--disabled: var(--primary); + --d-input-bg-color--disabled: var(--primary-very-low); + --d-input-text-color--disabled: var(--primary-medium); --d-input-border--disabled: 1px solid var(--primary-low); --d-nav-color: var(--primary); --d-nav-bg-color: transparent; @@ -137,6 +137,7 @@ span.relative-date { legend { color: var(--primary-high); font-weight: bold; + font-size: var(--font-down-1-rem); } label { diff --git a/app/assets/stylesheets/common/components/d-toggle-switch.scss b/app/assets/stylesheets/common/components/d-toggle-switch.scss index c30cb147b0e..4c2125df048 100644 --- a/app/assets/stylesheets/common/components/d-toggle-switch.scss +++ b/app/assets/stylesheets/common/components/d-toggle-switch.scss @@ -28,6 +28,7 @@ position: relative; display: inline-block; cursor: pointer; + margin: 0; } &__checkbox { diff --git a/app/assets/stylesheets/common/form-kit/_alert.scss b/app/assets/stylesheets/common/form-kit/_alert.scss new file mode 100644 index 00000000000..cab01fffa74 --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_alert.scss @@ -0,0 +1,7 @@ +.form-kit__alert { + //reset + margin: 0; + width: 100%; + border-radius: var(--d-border-radius); + box-sizing: border-box; +} diff --git a/app/assets/stylesheets/common/form-kit/_char-counter.scss b/app/assets/stylesheets/common/form-kit/_char-counter.scss new file mode 100644 index 00000000000..8df4e0f5435 --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_char-counter.scss @@ -0,0 +1,8 @@ +.form-kit__char-counter { + margin-left: auto; + padding-top: 0.15em; + + &.--exceeded { + color: var(--danger); + } +} diff --git a/app/assets/stylesheets/common/form-kit/_col.scss b/app/assets/stylesheets/common/form-kit/_col.scss new file mode 100644 index 00000000000..625fa2917ba --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_col.scss @@ -0,0 +1,69 @@ +.form-kit__col { + flex: 0 0 auto; + box-sizing: border-box; +} + +@include breakpoint("large") { + .--col-1, + .--col-2, + .--col-3, + .--col-4, + .--col-5, + .--col-6, + .--col-7, + .--col-8, + .--col-9, + .--col-10, + .--col-11, + .--col-12 { + width: 100% !important; + } +} + +.--col-1 { + width: 8.33333333%; +} + +.--col-2 { + width: 16.66666667%; +} + +.--col-3 { + width: 25%; +} + +.--col-4 { + width: 33.33333333%; +} + +.--col-5 { + width: 41.66666667%; +} + +.--col-6 { + width: 50%; +} + +.--col-7 { + width: 58.33333333%; +} + +.--col-8 { + width: 66.66666667%; +} + +.--col-9 { + width: 75%; +} + +.--col-10 { + width: 83.33333333%; +} + +.--col-11 { + width: 91.66666667%; +} + +.--col-12 { + width: 100%; +} diff --git a/app/assets/stylesheets/common/form-kit/_conditional-display.scss b/app/assets/stylesheets/common/form-kit/_conditional-display.scss new file mode 100644 index 00000000000..87c020f1445 --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_conditional-display.scss @@ -0,0 +1,5 @@ +.form-kit__conditional-display { + .form-kit__inline-radio { + padding-bottom: 0.25rem; + } +} diff --git a/app/assets/stylesheets/common/form-kit/_container.scss b/app/assets/stylesheets/common/form-kit/_container.scss new file mode 100644 index 00000000000..104dc5f7655 --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_container.scss @@ -0,0 +1,43 @@ +.form-kit__container { + display: flex; + gap: 0.25rem; + flex-direction: column; + align-items: flex-start; + + &-title { + display: flex; + align-items: center; + gap: 0.25em; + margin: 0; + font-size: var(--font-down-1-rem); + color: var(--primary-high); + font-weight: bold; + padding-bottom: 0.25em; + width: max-content; + } + + &-subtitle { + display: flex; + align-items: center; + gap: 0.25em; + margin: 0; + font-size: var(--font-down-1-rem); + color: var(--primary-high); + padding-bottom: 0.25em; + width: max-content; + } + + &-optional { + font-size: var(--font-down-2-rem); + color: var(--primary-medium); + font-weight: normal; + } + + &-content { + display: flex; + gap: 0.25em; + flex-direction: row; + align-items: flex-start; + max-width: 100%; + } +} diff --git a/app/assets/stylesheets/common/form-kit/_control-checkbox.scss b/app/assets/stylesheets/common/form-kit/_control-checkbox.scss new file mode 100644 index 00000000000..a524af6ba1c --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_control-checkbox.scss @@ -0,0 +1,25 @@ +.form-kit__control-checkbox { + &[type="checkbox"] { + margin: 0.17em; + margin-right: 0; + margin-left: 0; + } + + &-label { + display: flex; + gap: 0.5em; + font-weight: normal !important; + margin: 0; + color: var(--primary); + + .form-kit__field[data-disabled] & { + cursor: not-allowed; + } + } +} + +.form-kit__field-checkbox { + + .form-kit__field-checkbox { + margin-top: calc(-1 * var(--form-kit-gutter-y)); + } +} diff --git a/app/assets/stylesheets/common/form-kit/_control-code.scss b/app/assets/stylesheets/common/form-kit/_control-code.scss new file mode 100644 index 00000000000..105d4caa0f5 --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_control-code.scss @@ -0,0 +1,35 @@ +.form-kit__control-code { + height: 250px; + width: 100%; + + > .ace_editor { + box-sizing: border-box; + border: 1px solid var(--primary-400); + border-radius: var(--d-input-border-radius); + } + + &[data-disabled="false"] { + > .ace_editor { + @include default-input; + height: 100% !important; + + &.ace_focus { + border-color: var(--tertiary); + outline: 2px solid var(--tertiary); + outline-offset: -1px; + } + } + } + + .form-kit__field.has-error & { + border-color: var(--danger); + } + + &[data-disabled]:not([data-disabled="false"]) { + opacity: 0.5; + + .ace_scroller { + cursor: not-allowed !important; + } + } +} diff --git a/app/assets/stylesheets/common/form-kit/_control-composer.scss b/app/assets/stylesheets/common/form-kit/_control-composer.scss new file mode 100644 index 00000000000..ab6341152ae --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_control-composer.scss @@ -0,0 +1,26 @@ +.form-kit__control-composer { + // same default effective height (150px) than a text control + height: 189px; + + width: 100%; + + .d-editor-preview-wrapper { + display: none; + } + + .d-editor-button-bar { + > .btn { + border-radius: 0; + } + } + + .d-editor-textarea-wrapper { + @include default-input; + padding: 0 !important; + } + + .d-editor-input { + // same padding than a text control + padding: 0.5em; + } +} diff --git a/app/assets/stylesheets/common/form-kit/_control-icon.scss b/app/assets/stylesheets/common/form-kit/_control-icon.scss new file mode 100644 index 00000000000..26ea7558d3b --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_control-icon.scss @@ -0,0 +1,43 @@ +.form-kit__control-icon { + width: auto !important; + min-width: var(--form-kit-small-input); + .formatted-selection { + display: none !important; + } + + .d-icon-angle-down, + .d-icon-angle-up { + color: var(--primary-medium) !important; + } + + .select-kit-header-wrapper .d-icon:first-of-type { + color: var(--primary); + } + + .select-kit-header { + padding-inline: 0.65em !important; + height: 2em; + + @include breakpoint(mobile-large) { + height: 2.25em; + } + + &:hover:not(:disabled) { + .discourse-no-touch & { + background-color: var(--secondary); + color: var(--primary); + border: 1px solid var(--tertiary); + + .d-icon { + color: inherit; + } + } + } + } + + .form-kit__field.has-error & { + summary { + border-color: var(--danger); + } + } +} diff --git a/app/assets/stylesheets/common/form-kit/_control-image.scss b/app/assets/stylesheets/common/form-kit/_control-image.scss new file mode 100644 index 00000000000..4cfe4e57c1d --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_control-image.scss @@ -0,0 +1,10 @@ +.form-kit__control-image { + width: 100%; + + .uploaded-image-preview { + max-width: 100%; + width: 100%; + margin: 0; + border-radius: var(--d-input-border-radius); + } +} diff --git a/app/assets/stylesheets/common/form-kit/_control-input-group.scss b/app/assets/stylesheets/common/form-kit/_control-input-group.scss new file mode 100644 index 00000000000..ce79859c3b8 --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_control-input-group.scss @@ -0,0 +1,49 @@ +.form-kit__input-group { + position: relative; + display: flex; + align-items: stretch; + width: 100%; + + .form-kit-text { + display: flex; + align-items: center; + text-align: center; + white-space: nowrap; + padding-inline: 0.5em; + line-height: 2em; + background-color: var(--primary-low); + color: var(--primary-high); + border: 1px solid var(--primary-low-mid); + border-radius: var(--d-input-border-radius); + } + + .--col-12 { + width: auto; + } + + .form-kit__control-input { + z-index: 1; + width: 100% !important; + min-width: auto !important; + + &:hover, + &:focus { + z-index: 2; + } + } + + > :not(:last-child) { + .form-kit__control-input { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + } + + > :not(:first-child) { + margin-left: -1px; + .form-kit__control-input { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + } +} diff --git a/app/assets/stylesheets/common/form-kit/_control-input.scss b/app/assets/stylesheets/common/form-kit/_control-input.scss new file mode 100644 index 00000000000..5a6bc74ffc4 --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_control-input.scss @@ -0,0 +1,52 @@ +.form-kit__control-input { + @include default-input; + z-index: 1; + + margin: 0 !important; + width: 100% !important; + min-width: auto !important; + + .form-kit__field.has-error & { + border-color: var(--danger); + } + + &.has-prefix.has-suffix { + border-radius: 0; + } + + &.has-prefix:not(.has-suffix) { + border-radius: 0 var(--d-input-border-radius) var(--d-input-border-radius) 0; + } + + &.has-suffix:not(.has-prefix) { + border-radius: var(--d-input-border-radius) 0 0 var(--d-input-border-radius); + } + + &-wrapper { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + } +} + +.form-kit__before-input, +.form-kit__after-input { + border: 1px solid var(--primary-low-mid); + padding-inline: 0.5em; + height: 2em; + box-sizing: border-box; + background: var(--primary-low); + display: flex; + align-items: center; +} + +.form-kit__before-input { + margin-right: -0.25em; + border-radius: var(--d-input-border-radius) 0 0 var(--d-input-border-radius); +} + +.form-kit__after-input { + margin-left: -0.25em; + border-radius: 0 var(--d-input-border-radius) var(--d-input-border-radius) 0; +} diff --git a/app/assets/stylesheets/common/form-kit/_control-menu.scss b/app/assets/stylesheets/common/form-kit/_control-menu.scss new file mode 100644 index 00000000000..62d5b04f2e1 --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_control-menu.scss @@ -0,0 +1,20 @@ +.form-kit__control-menu { + @include default-input; +} + +.fk-d-menu { + .dropdown-menu { + min-width: 200px; + } + .dropdown-menu__item { + &:hover { + background: var(--d-hover); + } + + &:last-child { + .btn { + color: var(--tertiary); + } + } + } +} diff --git a/app/assets/stylesheets/common/form-kit/_control-password.scss b/app/assets/stylesheets/common/form-kit/_control-password.scss new file mode 100644 index 00000000000..c612b5052f3 --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_control-password.scss @@ -0,0 +1,27 @@ +.form-kit__control-password { + @include default-input; + border: 0 !important; + height: 100% !important; + width: 100% !important; + margin: 0 !important; + appearance: none !important; + outline: none !important; + + &:hover, + &:focus { + border: 0 !important; + } +} + +.form-kit__control-password-wrapper { + display: flex; + @include default-input; + padding: 0 !important; + width: inherit !important; + + &.--focused { + border-color: var(--tertiary); + outline: 2px solid var(--tertiary); + outline-offset: -2px; + } +} diff --git a/app/assets/stylesheets/common/form-kit/_control-radio-group.scss b/app/assets/stylesheets/common/form-kit/_control-radio-group.scss new file mode 100644 index 00000000000..4a3f0823710 --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_control-radio-group.scss @@ -0,0 +1,14 @@ +.form-kit__radio-group { + display: flex; + flex-direction: column; + gap: 0.75em; + + &-title { + display: flex; + align-items: center; + gap: 0.25em; + margin: 0; + font-size: var(--font-down-1-rem); + color: var(--primary-high); + } +} diff --git a/app/assets/stylesheets/common/form-kit/_control-radio.scss b/app/assets/stylesheets/common/form-kit/_control-radio.scss new file mode 100644 index 00000000000..225365db816 --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_control-radio.scss @@ -0,0 +1,26 @@ +.form-kit__control-radio { + &-label { + display: flex; + gap: 0.5em; + font-weight: normal !important; + margin: 0; + color: var(--primary); + + .form-kit__field[data-disabled] & { + cursor: not-allowed; + } + input[type="radio"] { + margin-right: 0; //old input overrule + } + } +} + +.form-kit__inline-radio { + display: flex; + gap: 1.5rem; + align-items: center; + + input[type="radio"] { + margin-right: 0; //old input overrule + } +} diff --git a/app/assets/stylesheets/common/form-kit/_control-select.scss b/app/assets/stylesheets/common/form-kit/_control-select.scss new file mode 100644 index 00000000000..9b9a49db1cc --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_control-select.scss @@ -0,0 +1,13 @@ +.form-kit__control-select { + @include default-input; + + padding: 0 2em 0 0.5em !important; + appearance: none; + background-image: svg-uri( + "" + ); + background-repeat: no-repeat; + background-position: right 0.5rem center; + background-size: 16px 12px; + cursor: pointer; +} diff --git a/app/assets/stylesheets/common/form-kit/_control-textarea.scss b/app/assets/stylesheets/common/form-kit/_control-textarea.scss new file mode 100644 index 00000000000..325b77fe8d0 --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_control-textarea.scss @@ -0,0 +1,15 @@ +.form-kit__control-textarea { + @include default-input; + + // reset textarea styles + width: 100% !important; + margin: 0 !important; + min-width: auto !important; + padding: 0.5em !important; + + // prevents firefox/chrome to add spacing under textarea + display: block; + + height: 150px !important; + border-radius: var(--d-input-border-radius); +} diff --git a/app/assets/stylesheets/common/form-kit/_default-input-mixin.scss b/app/assets/stylesheets/common/form-kit/_default-input-mixin.scss new file mode 100644 index 00000000000..b82b3a9f853 --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_default-input-mixin.scss @@ -0,0 +1,50 @@ +@mixin default-input { + width: auto !important; + height: 2em; + background: var(--secondary); + border: 1px solid var(--primary-low-mid); + border-radius: var(--d-input-border-radius); + padding: 0 0.5em !important; + box-sizing: border-box; + margin: 0 !important; + appearance: none; + + @include breakpoint(mobile-large) { + width: 100% !important; + height: 2.25em; + } + + &:focus, + &:focus-visible, + &:focus:focus-visible, + &:active { + //these importants are another great case for having a button element without that pesky default styling + &:not(:disabled) { + background-color: var(--secondary) !important; + color: var(--primary) !important; + border-color: var(--tertiary); + outline: 2px solid var(--tertiary); + outline-offset: -2px; + + .d-icon { + color: inherit !important; + } + } + } + + &:hover:not(:disabled) { + .discourse-no-touch & { + background-color: var(--secondary); + color: var(--primary); + border-color: var(--tertiary); + + .d-icon { + color: inherit; + } + } + } + + .has-errors & { + border-color: var(--danger); + } +} diff --git a/app/assets/stylesheets/common/form-kit/_errors-summary.scss b/app/assets/stylesheets/common/form-kit/_errors-summary.scss new file mode 100644 index 00000000000..38473b3f80d --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_errors-summary.scss @@ -0,0 +1,16 @@ +.form-kit__errors-summary { + padding: 1em; + border: 1px solid var(--danger); + background-color: var(--danger-low); + width: 100%; + border-radius: var(--d-border-radius); + box-sizing: border-box; + + .d-icon-exclamation-triangle { + color: var(--danger); + } + + ul { + margin-block: 0; + } +} diff --git a/app/assets/stylesheets/common/form-kit/_errors.scss b/app/assets/stylesheets/common/form-kit/_errors.scss new file mode 100644 index 00000000000..579fd5a5f21 --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_errors.scss @@ -0,0 +1,5 @@ +.form-kit__errors { + color: var(--danger); + margin: 0; + font-size: var(--font-down-1-rem); +} diff --git a/app/assets/stylesheets/common/form-kit/_field.scss b/app/assets/stylesheets/common/form-kit/_field.scss new file mode 100644 index 00000000000..da367b5912b --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_field.scss @@ -0,0 +1,55 @@ +.form-kit__field { + .form-kit__container-content { + align-items: flex-start; + flex-direction: column; + width: var(--form-kit-medium-input); + } + + &-textarea, + &-composer, + &-code, + &-image { + .form-kit__container-content { + width: var(--form-kit-large-input) !important; + } + } + + &-checkbox { + .form-kit__container-content { + width: 100% !important; + } + } + + &-toggle { + flex-direction: row; + align-items: center; + justify-content: space-between; + width: var(--form-kit-medium-input); + + .form-kit__container-content { + align-items: flex-end; + } + + .form-kit__container-title { + padding: 0; + } + } + + .form-kit__container-content { + &.--small { + width: var(--form-kit-small-input) !important; + } + + &.--medium { + width: var(--form-kit-medium-input); + } + + &.--large { + width: var(--form-kit-large-input); + } + + &.--full { + width: 100% !important; + } + } +} diff --git a/app/assets/stylesheets/common/form-kit/_form-kit.scss b/app/assets/stylesheets/common/form-kit/_form-kit.scss new file mode 100644 index 00000000000..05b02fe40af --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_form-kit.scss @@ -0,0 +1,6 @@ +.form-kit { + display: flex; + flex-direction: column; + gap: 1.5em; + align-items: flex-start; +} diff --git a/app/assets/stylesheets/common/form-kit/_index.scss b/app/assets/stylesheets/common/form-kit/_index.scss new file mode 100644 index 00000000000..53c04bf0cf1 --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_index.scss @@ -0,0 +1,27 @@ +@import "_default-input-mixin"; +@import "_alert"; +@import "_char-counter"; +@import "_col"; +@import "_conditional-display"; +@import "_container"; +@import "_control-checkbox"; +@import "_control-code"; +@import "_control-composer"; +@import "_control-icon"; +@import "_control-password"; +@import "_control-image"; +@import "_control-input"; +@import "_control-input-group"; +@import "_control-menu"; +@import "_control-radio"; +@import "_control-radio-group"; +@import "_control-select"; +@import "_control-textarea"; +@import "_errors"; +@import "_errors-summary"; +@import "_field"; +@import "_form-kit"; +@import "_meta"; +@import "_row"; +@import "_section"; +@import "_variables"; diff --git a/app/assets/stylesheets/common/form-kit/_meta.scss b/app/assets/stylesheets/common/form-kit/_meta.scss new file mode 100644 index 00000000000..f3c470a3220 --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_meta.scss @@ -0,0 +1,16 @@ +.form-kit__meta { + display: flex; + flex-wrap: nowrap; + justify-content: space-between; + align-items: start; + gap: 0.25em; + font-size: var(--font-down-2-rem); + color: var(--primary-high); + width: 100%; + min-height: 20px; + line-height: var(--line-height-medium); + + &-description { + margin: 0; + } +} diff --git a/app/assets/stylesheets/common/form-kit/_row.scss b/app/assets/stylesheets/common/form-kit/_row.scss new file mode 100644 index 00000000000..ead3b01762e --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_row.scss @@ -0,0 +1,58 @@ +// .form-kit__row { +// display: grid; +// grid-auto-flow: column; +// column-gap: 0.5em; +// row-gap: 0.25em; +// max-width: 100%; + +// .form-kit__col, +// .form-kit__container { +// display: contents; +// } + +// .form-kit__container-content { +// width: auto !important; +// } + +// .form-kit__button { +// grid-row: 2; +// height: 2em; +// } +// } + +.form-kit__row { + display: flex; + flex-wrap: wrap; + margin-right: calc(-0.5 * var(--form-kit-gutter-x)); + margin-left: calc(-0.5 * var(--form-kit-gutter-x)); + row-gap: calc(var(--form-kit-gutter-y) + 1.75em); + padding-top: 1.75em; + + > * { + flex-shrink: 0; + + padding-right: calc(var(--form-kit-gutter-x) * 0.5); + padding-left: calc(var(--form-kit-gutter-x) * 0.5); + } + + > *:not(.col-*) { + width: 100%; + } + + .form-kit__container-content { + width: 100% !important; + } + + .form-kit__col:not(:has(.form-kit__button)) { + position: relative; + } + + .form-kit__button { + height: 2em; + } + + .form-kit__container-title { + position: absolute; + top: -1.75em; + } +} diff --git a/app/assets/stylesheets/common/form-kit/_section.scss b/app/assets/stylesheets/common/form-kit/_section.scss new file mode 100644 index 00000000000..3caced8ab26 --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_section.scss @@ -0,0 +1,16 @@ +.form-kit__section { + display: flex; + flex-direction: column; + gap: 1.5rem; + width: 100%; + + &.form-kit__actions { + flex-direction: row; + gap: 1rem; + } +} + +.form-kit__section-title { + margin: 0; + font-size: var(--font-up-1-rem); +} diff --git a/app/assets/stylesheets/common/form-kit/_variables.scss b/app/assets/stylesheets/common/form-kit/_variables.scss new file mode 100644 index 00000000000..494f2df7965 --- /dev/null +++ b/app/assets/stylesheets/common/form-kit/_variables.scss @@ -0,0 +1,7 @@ +.form-kit { + --form-kit-gutter-x: 1rem; + --form-kit-gutter-y: 1rem; + --form-kit-large-input: 325px; + --form-kit-medium-input: 200px; + --form-kit-small-input: 75px; +} diff --git a/app/assets/stylesheets/common/foundation/base.scss b/app/assets/stylesheets/common/foundation/base.scss index fe74047978e..d7d5700ba28 100644 --- a/app/assets/stylesheets/common/foundation/base.scss +++ b/app/assets/stylesheets/common/foundation/base.scss @@ -9,8 +9,8 @@ --d-border-radius: 2px; --d-border-radius-large: 2px; --d-nav-pill-border-radius: var(--d-border-radius); - --d-button-border-radius: var(--d-border-radius); - --d-input-border-radius: var(--d-border-radius); + --d-button-border-radius: 2px; + --d-input-border-radius: 2px; --d-content-background: initial; --d-font-family--monospace: Consolas, Menlo, Monaco, "Lucida Console", "Liberation Mono", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", diff --git a/app/assets/stylesheets/mobile.scss b/app/assets/stylesheets/mobile.scss index b1a6711618a..dd6f760c46f 100644 --- a/app/assets/stylesheets/mobile.scss +++ b/app/assets/stylesheets/mobile.scss @@ -1,10 +1,8 @@ @import "common"; - @import "mobile/_index"; // Import all component-specific files @import "mobile/components/_index"; - @import "mobile/select-kit/_index"; - @import "mobile/float-kit/_index"; +@import "mobile/form-kit/_index"; diff --git a/app/assets/stylesheets/mobile/admin_badges.scss b/app/assets/stylesheets/mobile/admin_badges.scss index b3f1d0ca559..40e7334d0f6 100644 --- a/app/assets/stylesheets/mobile/admin_badges.scss +++ b/app/assets/stylesheets/mobile/admin_badges.scss @@ -24,4 +24,13 @@ width: 100%; } } + + .badges-header .create-new-badge .btn-default { + .d-button-label { + display: none; + } + .d-icon { + margin-right: 0; + } + } } diff --git a/app/assets/stylesheets/mobile/form-kit/_control-input.scss b/app/assets/stylesheets/mobile/form-kit/_control-input.scss new file mode 100644 index 00000000000..2ffda7a96fe --- /dev/null +++ b/app/assets/stylesheets/mobile/form-kit/_control-input.scss @@ -0,0 +1,5 @@ +.form-kit__before-input, +.form-kit__after-input { + font-size: var(--font-size-ios-input); + height: 2.25em; +} diff --git a/app/assets/stylesheets/mobile/form-kit/_control-menu.scss b/app/assets/stylesheets/mobile/form-kit/_control-menu.scss new file mode 100644 index 00000000000..6277b24c5db --- /dev/null +++ b/app/assets/stylesheets/mobile/form-kit/_control-menu.scss @@ -0,0 +1,19 @@ +.form-kit { + &__control-menu { + justify-content: space-between; + min-width: var(--form-kit-small-input); + } + + &__control-menu-item { + .btn:focus, + .btn:active { + .discourse-touch & { + color: var(--primary); + background: rgba(0, 0, 0, 0); + } + } + &:last-of-type .btn { + color: var(--tertiary); + } + } +} diff --git a/app/assets/stylesheets/mobile/form-kit/_control-text.scss b/app/assets/stylesheets/mobile/form-kit/_control-text.scss new file mode 100644 index 00000000000..2386799a662 --- /dev/null +++ b/app/assets/stylesheets/mobile/form-kit/_control-text.scss @@ -0,0 +1,3 @@ +.form-kit__control-text { + height: 150px; +} diff --git a/app/assets/stylesheets/mobile/form-kit/_field.scss b/app/assets/stylesheets/mobile/form-kit/_field.scss new file mode 100644 index 00000000000..eb47a6fd708 --- /dev/null +++ b/app/assets/stylesheets/mobile/form-kit/_field.scss @@ -0,0 +1,4 @@ +.form-kit__field .form-kit__container-content, +.form-kit__container { + width: 100% !important; +} diff --git a/app/assets/stylesheets/mobile/form-kit/_index.scss b/app/assets/stylesheets/mobile/form-kit/_index.scss new file mode 100644 index 00000000000..68a507a61ee --- /dev/null +++ b/app/assets/stylesheets/mobile/form-kit/_index.scss @@ -0,0 +1,5 @@ +@import "_control-input"; +@import "_control-menu"; +@import "_control-text"; +@import "_field"; +@import "_row"; diff --git a/app/assets/stylesheets/mobile/form-kit/_row.scss b/app/assets/stylesheets/mobile/form-kit/_row.scss new file mode 100644 index 00000000000..47a11f59791 --- /dev/null +++ b/app/assets/stylesheets/mobile/form-kit/_row.scss @@ -0,0 +1,5 @@ +.form-kit__row { + .form-kit__button { + height: 2.25em; + } +} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 76a53f9f953..1a30b5e896f 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -2091,6 +2091,25 @@ en: modal: close: "close" dismiss_error: "Dismiss error" + form_kit: + reset: Reset + optional: optional + errors_summary_title: "This form contains errors:" + dirty_form: "You didn't submit your changes! Are you sure you want to leave?" + errors: + required: "Required" + invalid_url: "Must be a valid URL" + not_accepted: "Must be accepted" + not_a_number: "Must be a number" + too_high: "Must be at most %{count}" + too_low: "Must be at least %{count}" + too_long: + one: "Must be at most %{count} character" + other: "Must be at most %{count} characters" + too_short: + one: "Must be at least %{count} character" + other: "Must be at least %{count} characters" + close: "Close" assets_changed_confirm: "This site just received a software update. Get the latest version now?" logout: "You were logged out." @@ -6869,6 +6888,8 @@ en: confirm: "Yes, update password policy" badges: + disable_system: This badge is a system badge and cannot be disabled and/or deleted. + status: Status title: Badges new_badge: New Badge new: New @@ -6876,8 +6897,8 @@ en: badge: Badge display_name: Display Name description: Description - long_description: Long Description - badge_type: Badge Type + long_description: Long description + badge_type: Badge type badge_grouping: Group badge_groupings: modal_title: Badge Groupings @@ -6906,10 +6927,10 @@ en: icon: Icon image: Image graphic: Graphic + icon_or_image: A badge requires an icon or an image icon_help: "Enter a Font Awesome icon name (use prefix 'far-' for regular icons and 'fab-' for brand icons)" - image_help: "Uploading an image overrides icon field if both are set." - select_an_icon: "Select an Icon" - upload_an_image: "Upload an Image" + select_an_icon: "Select an icon" + upload_an_image: "Upload an image" read_only_setting_help: "Customize text" query: Badge Query (SQL) target_posts: Query targets posts diff --git a/plugins/poll/assets/stylesheets/common/poll-ui-builder.scss b/plugins/poll/assets/stylesheets/common/poll-ui-builder.scss index ab68a471371..2e9e7cb9f77 100644 --- a/plugins/poll/assets/stylesheets/common/poll-ui-builder.scss +++ b/plugins/poll/assets/stylesheets/common/poll-ui-builder.scss @@ -32,7 +32,7 @@ } } - .poll-public .d-toggle-switch--label { + .poll-public .d-toggle-switch__label { margin-bottom: 0; } diff --git a/plugins/styleguide/assets/javascripts/discourse/components/sections/atoms/05-forms.hbs b/plugins/styleguide/assets/javascripts/discourse/components/sections/atoms/05-forms.hbs new file mode 100644 index 00000000000..5e101e50605 --- /dev/null +++ b/plugins/styleguide/assets/javascripts/discourse/components/sections/atoms/05-forms.hbs @@ -0,0 +1,250 @@ +

Controls

+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+ + + +
+
+ + +
+ + + +
+
+ + +
+ + + +
+
+ + +
+ + + +
+
+ + +
+ + + Yes + No + + +
+
+ + +
+ + Accept the contract + +
+
+ + +
+ + + +
+
+ + +
+ + + +
+
+ + +
+ + + Yes + + No + + +
+
+ + +
+ + + Yes + No + + +
+
+ +

Layout

+ + +
+ + Content + +
+
+ + +
+ + You can edit this form. + +
+
+ + +
+ + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + +
+
+ +

Validation

+ + +
+ + + +
+
\ No newline at end of file diff --git a/plugins/styleguide/assets/javascripts/discourse/components/sections/atoms/05-input-fields.hbs b/plugins/styleguide/assets/javascripts/discourse/components/sections/atoms/05-input-fields.hbs deleted file mode 100644 index 09ee58198a0..00000000000 --- a/plugins/styleguide/assets/javascripts/discourse/components/sections/atoms/05-input-fields.hbs +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - -