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 @@
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 @@
-
-
-
-
+
+
+
+ {{#each this.badgeTypes as |badgeType|}}
+
+ {{badgeType.name}}
+
+ {{/each}}
+
+
-
-
{{i18n "admin.badges.description"}}
- {{#if this.buffered.system}}
-
-
-
+
+
+ {{i18n "admin.badges.select_an_icon"}}
+
+
+ {{i18n "admin.badges.upload_an_image"}}
+
+
+
+
+
- {{i18n "admin.badges.read_only_setting_help"}}
-
-
- {{else}}
-
- {{/if}}
-
-
-
-
{{i18n
- "admin.badges.long_description"
- }}
- {{#if this.buffered.system}}
-
-
-
+
+
+
+
- {{i18n "admin.badges.read_only_setting_help"}}
-
-
- {{else}}
-
- {{/if}}
-
+
+
+
+
+
- {{#if this.siteSettings.enable_badge_sql}}
-
-
{{i18n "admin.badges.query"}}
-
-
-
- {{#if this.hasQuery}}
-
+
+ {{this.model.description}}
+
+
- {{i18n "admin.badges.preview.link_text"}}
- |
-
- {{i18n "admin.badges.preview.plan_text"}}
-
- {{#if this.preview_loading}}
- {{i18n "loading"}}
- {{/if}}
-
-
-
-
- {{i18n "admin.badges.auto_revoke"}}
-
-
-
-
-
-
- {{i18n "admin.badges.target_posts"}}
-
-
-
-
- {{i18n "admin.badges.trigger"}}
-
-
- {{/if}}
+ {{d-icon "pencil-alt"}}
+
+
+ {{else}}
+
+
+
{{/if}}
-
-
-
-
- {{i18n "admin.badges.allow_title"}}
-
-
+ {{#if this.readOnly}}
+
+
+ {{this.model.long_description}}
+
-
-
-
- {{i18n "admin.badges.multiple_grant"}}
-
-
+
+ {{d-icon "pencil-alt"}}
+
+
+ {{else}}
+
+
+
+ {{/if}}
+
-
-
-
- {{i18n "admin.badges.listable"}}
-
-
+ {{#if this.siteSettings.enable_badge_sql}}
+
+
+
+
-
-
-
+
- {{i18n "admin.badges.show_posts"}}
-
+
+
+
+
+
+ {{i18n "admin.badges.auto_revoke"}}
+
+
+
+
+
+ {{i18n "admin.badges.target_posts"}}
+
+
+
+
+
+ {{#each this.badgeTriggers as |badgeType|}}
+
+ {{badgeType.name}}
+
+ {{/each}}
+
+
+ {{/if}}
+
+ {{/if}}
+
+
+
+
+ {{#each this.badgeGroupings as |grouping|}}
+ {{grouping.name}}
+ {{/each}}
+
+ Add new group
+
+
+
+
+ {{i18n "admin.badges.allow_title"}}
+
+
+
+ {{i18n "admin.badges.multiple_grant"}}
+
+
+
+ {{i18n "admin.badges.listable"}}
+
+
+
+ {{i18n "admin.badges.show_posts"}}
+
+
+
+
+
+
+
+
+ {{#unless this.readOnly}}
+
+ {{i18n "admin.badges.delete"}}
+
+ {{/unless}}
+
+
+ {{#if this.grant_count}}
+
+
+
+ {{html-safe
+ (i18n
+ "badges.awarded"
+ count=this.displayCount
+ number=(number this.displayCount)
+ )
+ }}
+
-
-
-
-
-
- {{this.savingStatus}}
- {{#unless this.readOnly}}
-
- {{/unless}}
-
-
-
-
-{{#if this.grant_count}}
-
-
-
- {{html-safe
- (i18n
- "badges.awarded"
- count=this.displayCount
- number=(number this.displayCount)
- )
- }}
-
-
-
-{{/if}}
\ No newline at end of file
+ {{/if}}
+
\ No newline at end of file
diff --git a/app/assets/javascripts/admin/addon/components/ace-editor.hbs b/app/assets/javascripts/discourse/app/components/ace-editor.hbs
similarity index 56%
rename from app/assets/javascripts/admin/addon/components/ace-editor.hbs
rename to app/assets/javascripts/discourse/app/components/ace-editor.hbs
index ac28b8afdb5..8c17226c000 100644
--- a/app/assets/javascripts/admin/addon/components/ace-editor.hbs
+++ b/app/assets/javascripts/discourse/app/components/ace-editor.hbs
@@ -1,5 +1,5 @@
{{#if this.isLoading}}
{{loading-spinner size="small"}}
{{else}}
-
{{this.content}}
+
{{this.content}}
{{/if}}
\ No newline at end of file
diff --git a/app/assets/javascripts/admin/addon/components/ace-editor.js b/app/assets/javascripts/discourse/app/components/ace-editor.js
similarity index 95%
rename from app/assets/javascripts/admin/addon/components/ace-editor.js
rename to app/assets/javascripts/discourse/app/components/ace-editor.js
index d9e4d2d1353..ac40995d624 100644
--- a/app/assets/javascripts/admin/addon/components/ace-editor.js
+++ b/app/assets/javascripts/discourse/app/components/ace-editor.js
@@ -1,6 +1,7 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import { next } from "@ember/runloop";
+import { service } from "@ember/service";
import { classNames } from "@ember-decorators/component";
import { observes } from "@ember-decorators/object";
import loadAce from "discourse/lib/load-ace-editor";
@@ -12,6 +13,8 @@ const COLOR_VARS_REGEX =
@classNames("ace-wrapper")
export default class AceEditor extends Component {
+ @service appEvents;
+
isLoading = true;
mode = "css";
disabled = false;
@@ -117,8 +120,12 @@ export default class AceEditor extends Component {
});
editor.getSession().setMode("ace/mode/" + this.mode);
editor.on("change", () => {
- this._skipContentChangeEvent = true;
- this.set("content", editor.getSession().getValue());
+ if (this.onChange) {
+ this.onChange(editor.getSession().getValue());
+ } else {
+ this._skipContentChangeEvent = true;
+ this.set("content", editor.getSession().getValue());
+ }
});
if (this.save) {
editor.commands.addCommand({
diff --git a/app/assets/javascripts/discourse/app/components/d-editor.hbs b/app/assets/javascripts/discourse/app/components/d-editor.hbs
index 54a517b1a26..c0d72c0a69f 100644
--- a/app/assets/javascripts/discourse/app/components/d-editor.hbs
+++ b/app/assets/javascripts/discourse/app/components/d-editor.hbs
@@ -67,6 +67,7 @@
@focusIn={{action "focusIn"}}
@focusOut={{action "focusOut"}}
class="d-editor-input"
+ @id={{this.textAreaId}}
/>
-
+
{{! template-lint-disable no-redundant-role }}
-
- {{this.computedLabel}}
-
+ {{#if this.computedLabel}}
+
+ {{this.computedLabel}}
+
+ {{/if}}
diff --git a/app/assets/javascripts/discourse/app/components/form.gjs b/app/assets/javascripts/discourse/app/components/form.gjs
new file mode 100644
index 00000000000..1740cef7b3b
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/form.gjs
@@ -0,0 +1,3 @@
+import Form from "discourse/form-kit/components/fk/form";
+
+export default Form;
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/alert.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/alert.gjs
new file mode 100644
index 00000000000..624ec83a7f9
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/alert.gjs
@@ -0,0 +1,18 @@
+import Component from "@glimmer/component";
+import icon from "discourse-common/helpers/d-icon";
+
+export default class FKAlert extends Component {
+ get type() {
+ return this.args.type || "info";
+ }
+
+
+
+ {{#if @icon}}
+ {{icon @icon}}
+ {{/if}}
+
+ {{yield}}
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/char-counter.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/char-counter.gjs
new file mode 100644
index 00000000000..4dacc63a0b2
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/char-counter.gjs
@@ -0,0 +1,22 @@
+import Component from "@glimmer/component";
+import { gt, lt } from "truth-helpers";
+import concatClass from "discourse/helpers/concat-class";
+
+export default class FKCharCounter extends Component {
+ get currentLength() {
+ return this.args.value?.length || 0;
+ }
+
+
+
+ {{this.currentLength}}/{{@maxLength}}
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/col.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/col.gjs
new file mode 100644
index 00000000000..f6071d0759f
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/col.gjs
@@ -0,0 +1,10 @@
+import { concat } from "@ember/helper";
+import concatClass from "discourse/helpers/concat-class";
+
+const FKCol =
+
+ {{yield}}
+
+ ;
+
+export default FKCol;
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/collection.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/collection.gjs
new file mode 100644
index 00000000000..b6aab139f01
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/collection.gjs
@@ -0,0 +1,40 @@
+import Component from "@glimmer/component";
+import { hash } from "@ember/helper";
+import { action } from "@ember/object";
+import FKField from "discourse/form-kit/components/fk/field";
+
+export default class FKCollection extends Component {
+ @action
+ remove(index) {
+ this.args.remove(this.args.name, index);
+ }
+
+ get collectionValue() {
+ return this.args.data.get(this.args.name);
+ }
+
+
+
+ {{#each this.collectionValue key="index" as |data index|}}
+ {{yield
+ (hash
+ Field=(component
+ FKField
+ errors=@errors
+ collectionName=@name
+ collectionIndex=index
+ addError=@addError
+ data=@data
+ set=@set
+ registerField=@registerField
+ unregisterField=@unregisterField
+ triggerRevalidationFor=@triggerRevalidationFor
+ )
+ remove=this.remove
+ )
+ index
+ }}
+ {{/each}}
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/container.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/container.gjs
new file mode 100644
index 00000000000..132212e7a82
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/container.gjs
@@ -0,0 +1,22 @@
+import FormText from "discourse/form-kit/components/fk/text";
+import concatClass from "discourse/helpers/concat-class";
+
+const FKContainer =
+
+ {{#if @title}}
+
+ {{@title}}
+
+ {{/if}}
+
+ {{#if @subtitle}}
+
{{@subtitle}}
+ {{/if}}
+
+
+ {{yield}}
+
+
+ ;
+
+export default FKContainer;
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control-wrapper.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control-wrapper.gjs
new file mode 100644
index 00000000000..7207f46bf3c
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control-wrapper.gjs
@@ -0,0 +1,105 @@
+import Component from "@glimmer/component";
+import { concat } from "@ember/helper";
+import { action } from "@ember/object";
+import FKLabel from "discourse/form-kit/components/fk/label";
+import FKMeta from "discourse/form-kit/components/fk/meta";
+import FormText from "discourse/form-kit/components/fk/text";
+import concatClass from "discourse/helpers/concat-class";
+import i18n from "discourse-common/helpers/i18n";
+
+export default class FKControlWrapper extends Component {
+ constructor() {
+ super(...arguments);
+
+ this.args.field.setType(this.controlType);
+ }
+
+ get controlType() {
+ if (this.args.component.controlType === "input") {
+ return this.args.component.controlType + "-" + (this.args.type || "text");
+ }
+
+ return this.args.component.controlType;
+ }
+
+ @action
+ setFieldType() {
+ this.args.field.type = this.controlType;
+ }
+
+ get error() {
+ return (this.args.errors ?? {})[this.args.field.name];
+ }
+
+ normalizeName(name) {
+ return name.replace(/\./g, "-");
+ }
+
+
+
+ {{#if @field.showTitle}}
+
+ {{@field.title}}
+
+ {{#unless @field.required}}
+ ({{i18n
+ "form_kit.optional"
+ }})
+ {{/unless}}
+
+ {{/if}}
+
+ {{#if @field.subtitle}}
+
{{@field.subtitle}}
+ {{/if}}
+
+
+ <@component
+ @field={{@field}}
+ @value={{@value}}
+ @type={{@type}}
+ @yesLabel={{@yesLabel}}
+ @noLabel={{@noLabel}}
+ @lang={{@lang}}
+ @before={{@before}}
+ @after={{@after}}
+ @height={{@height}}
+ @selection={{@selection}}
+ id={{@field.id}}
+ name={{@field.name}}
+ aria-invalid={{if this.error "true"}}
+ aria-describedby={{if this.error @field.errorId}}
+ ...attributes
+ as |components|
+ >
+ {{yield components}}
+ @component>
+
+
+
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/checkbox.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/checkbox.gjs
new file mode 100644
index 00000000000..110b4bb3f68
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/checkbox.gjs
@@ -0,0 +1,28 @@
+import Component from "@glimmer/component";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import { eq } from "truth-helpers";
+import FKLabel from "discourse/form-kit/components/fk/label";
+
+export default class FKControlCheckbox extends Component {
+ static controlType = "checkbox";
+
+ @action
+ handleInput() {
+ this.args.field.set(!this.args.value);
+ }
+
+
+
+
+ {{yield}}
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/code.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/code.gjs
new file mode 100644
index 00000000000..acab741883d
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/code.gjs
@@ -0,0 +1,36 @@
+import Component from "@glimmer/component";
+import { action } from "@ember/object";
+import { htmlSafe } from "@ember/template";
+import AceEditor from "discourse/components/ace-editor";
+import { escapeExpression } from "discourse/lib/utilities";
+
+export default class FKControlCode extends Component {
+ static controlType = "code";
+
+ initialValue = this.args.value || "";
+
+ @action
+ handleInput(content) {
+ this.args.field.set(content);
+ }
+
+ get style() {
+ if (!this.args.height) {
+ return;
+ }
+
+ return `height: ${htmlSafe(escapeExpression(this.args.height) + "px")}`;
+ }
+
+
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/composer.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/composer.gjs
new file mode 100644
index 00000000000..5459704bc88
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/composer.gjs
@@ -0,0 +1,33 @@
+import Component from "@glimmer/component";
+import { action } from "@ember/object";
+import { htmlSafe } from "@ember/template";
+import DEditor from "discourse/components/d-editor";
+import { escapeExpression } from "discourse/lib/utilities";
+
+export default class FKControlComposer extends Component {
+ static controlType = "composer";
+
+ @action
+ handleInput(event) {
+ this.args.field.set(event.target.value);
+ }
+
+ get style() {
+ if (this.args.height) {
+ return;
+ }
+
+ return `height: ${htmlSafe(escapeExpression(this.args.height) + "px")}`;
+ }
+
+
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/conditional-content.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/conditional-content.gjs
new file mode 100644
index 00000000000..bbe77f2c531
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/conditional-content.gjs
@@ -0,0 +1,46 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import { hash } from "@ember/helper";
+import { action } from "@ember/object";
+import FKControlConditionalDisplayCondition from "./conditional-content/condition";
+import FKControlConditionalContentContent from "./conditional-content/content";
+
+const Conditions =
+
+ {{yield
+ (component
+ FKControlConditionalDisplayCondition
+ activeName=@activeName
+ setCondition=@setCondition
+ )
+ }}
+
+ ;
+
+const Contents =
+ {{yield
+ (component FKControlConditionalContentContent activeName=@activeName)
+ }}
+ ;
+
+export default class FKControlConditionalContent extends Component {
+ @tracked activeName = this.args.activeName;
+
+ @action
+ setCondition(name) {
+ this.activeName = name;
+ }
+
+
+
+ {{yield
+ (hash
+ Conditions=(component
+ Conditions activeName=this.activeName setCondition=this.setCondition
+ )
+ Contents=(component Contents activeName=this.activeName)
+ )
+ }}
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/conditional-content/condition.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/conditional-content/condition.gjs
new file mode 100644
index 00000000000..0cfe70ad8f7
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/conditional-content/condition.gjs
@@ -0,0 +1,24 @@
+import { fn } from "@ember/helper";
+import { on } from "@ember/modifier";
+import { eq } from "truth-helpers";
+import FKLabel from "discourse/form-kit/components/fk/label";
+import uniqueId from "discourse/helpers/unique-id";
+
+const FKControlConditionalContentOption =
+ {{#let (uniqueId) as |uuid|}}
+
+
+
+ {{yield}}
+
+ {{/let}}
+ ;
+
+export default FKControlConditionalContentOption;
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/conditional-content/content.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/conditional-content/content.gjs
new file mode 100644
index 00000000000..a829796a695
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/conditional-content/content.gjs
@@ -0,0 +1,11 @@
+import { eq } from "truth-helpers";
+
+const FKControlConditionalContentItem =
+ {{#if (eq @name @activeName)}}
+
+ {{yield}}
+
+ {{/if}}
+ ;
+
+export default FKControlConditionalContentItem;
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/icon.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/icon.gjs
new file mode 100644
index 00000000000..d11c115b252
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/icon.gjs
@@ -0,0 +1,29 @@
+import Component from "@glimmer/component";
+import { hash } from "@ember/helper";
+import { action } from "@ember/object";
+import IconPicker from "select-kit/components/icon-picker";
+
+export default class FKControlIcon extends Component {
+ static controlType = "icon";
+
+ @action
+ handleInput(value) {
+ this.args.field.set(value);
+ }
+
+
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/image.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/image.gjs
new file mode 100644
index 00000000000..b200002b358
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/image.gjs
@@ -0,0 +1,28 @@
+import Component from "@glimmer/component";
+import { concat } from "@ember/helper";
+import { action } from "@ember/object";
+import UppyImageUploader from "discourse/components/uppy-image-uploader";
+
+export default class FKControlImage extends Component {
+ static controlType = "image";
+
+ @action
+ setImage(upload) {
+ this.args.field.set(upload);
+ }
+
+ @action
+ removeImage() {
+ this.setImage(undefined);
+ }
+
+
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/input-group.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/input-group.gjs
new file mode 100644
index 00000000000..5d80b4d199b
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/input-group.gjs
@@ -0,0 +1,25 @@
+import { hash } from "@ember/helper";
+import FKField from "discourse/form-kit/components/fk/field";
+
+const FKControlInputGroup =
+
+ {{yield
+ (hash
+ Field=(component
+ FKField
+ errors=@errors
+ addError=@addError
+ data=@data
+ set=@set
+ remove=@remove
+ registerField=@registerField
+ unregisterField=@unregisterField
+ triggerRevalidationFor=@triggerRevalidationFor
+ showMeta=false
+ )
+ )
+ }}
+
+ ;
+
+export default FKControlInputGroup;
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/input.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/input.gjs
new file mode 100644
index 00000000000..51a53379092
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/input.gjs
@@ -0,0 +1,85 @@
+import Component from "@glimmer/component";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import concatClass from "discourse/helpers/concat-class";
+
+const SUPPORTED_TYPES = [
+ "color",
+ "date",
+ "datetime-local",
+ "email",
+ "hidden",
+ "month",
+ "number",
+ "password",
+ "range",
+ "search",
+ "tel",
+ "text",
+ "time",
+ "url",
+ "week",
+];
+
+export default class FKControlInput extends Component {
+ static controlType = "input";
+
+ constructor(owner, args) {
+ super(...arguments);
+
+ if (["checkbox", "radio"].includes(args.type)) {
+ throw new Error(
+ `input component does not support @type="${args.type}" as there is a dedicated component for this.`
+ );
+ }
+
+ if (args.type && !SUPPORTED_TYPES.includes(args.type)) {
+ throw new Error(
+ `input component does not support @type="${
+ args.type
+ }", must be one of ${SUPPORTED_TYPES.join(", ")}!`
+ );
+ }
+ }
+
+ get type() {
+ return this.args.type ?? "text";
+ }
+
+ @action
+ handleInput(event) {
+ const value =
+ event.target.value === ""
+ ? undefined
+ : this.type === "number"
+ ? parseFloat(event.target.value)
+ : event.target.value;
+
+ this.args.field.set(value);
+ }
+
+
+
+ {{#if @before}}
+ {{@before}}
+ {{/if}}
+
+
+
+ {{#if @after}}
+ {{@after}}
+ {{/if}}
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/menu.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/menu.gjs
new file mode 100644
index 00000000000..c8ceb7adfb7
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/menu.gjs
@@ -0,0 +1,57 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import { hash } from "@ember/helper";
+import { action } from "@ember/object";
+import DMenu from "discourse/components/d-menu";
+import DropdownMenu from "discourse/components/dropdown-menu";
+import FKControlMenuContainer from "discourse/form-kit/components/fk/control/menu/container";
+import FKControlMenuDivider from "discourse/form-kit/components/fk/control/menu/divider";
+import FKControlMenuItem from "discourse/form-kit/components/fk/control/menu/item";
+import icon from "discourse-common/helpers/d-icon";
+
+export default class FKControlMenu extends Component {
+ static controlType = "menu";
+
+ @tracked menuApi;
+
+ @action
+ registerMenuApi(api) {
+ this.menuApi = api;
+ }
+
+
+
+ <:trigger>
+
+ {{@selection}}
+
+ {{icon "angle-down"}}
+
+ <:content>
+
+ {{yield
+ (hash
+ Item=(component
+ FKControlMenuItem
+ item=menu.item
+ field=@field
+ menuApi=this.menuApi
+ )
+ Divider=(component FKControlMenuDivider divider=menu.divider)
+ Container=FKControlMenuContainer
+ )
+ }}
+
+
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/menu/container.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/menu/container.gjs
new file mode 100644
index 00000000000..c6db6539df4
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/menu/container.gjs
@@ -0,0 +1,7 @@
+const FKControlMenuContainer =
+
+ ;
+
+export default FKControlMenuContainer;
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/menu/divider.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/menu/divider.gjs
new file mode 100644
index 00000000000..bb99d2da7eb
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/menu/divider.gjs
@@ -0,0 +1,5 @@
+const FKControlMenuDivider =
+ <@divider class="form-kit__control-menu-divider" />
+ ;
+
+export default FKControlMenuDivider;
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/menu/item.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/menu/item.gjs
new file mode 100644
index 00000000000..7fa175a7ddb
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/menu/item.gjs
@@ -0,0 +1,31 @@
+import Component from "@glimmer/component";
+import { action } from "@ember/object";
+import DButton from "discourse/components/d-button";
+
+export default class FKControlMenuItem extends Component {
+ @action
+ handleInput() {
+ this.args.menuApi.close();
+
+ if (this.args.action) {
+ this.args.action(this.args.value, {
+ set: this.args.set,
+ });
+ } else {
+ this.args.field.set(this.args.value);
+ }
+ }
+
+
+ <@item class="form-kit__control-menu-item" data-value={{@value}}>
+
+ {{yield}}
+
+ @item>
+
+}
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/password.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/password.gjs
new file mode 100644
index 00000000000..669d7a62755
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/password.gjs
@@ -0,0 +1,79 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import { modifier as modifierFn } from "ember-modifier";
+import { eq } from "truth-helpers";
+import DButton from "discourse/components/d-button";
+import concatClass from "discourse/helpers/concat-class";
+
+const TYPES = {
+ text: "text",
+ password: "password",
+};
+
+export default class FKControlInput extends Component {
+ static controlType = "password";
+
+ @tracked type = TYPES.password;
+ @tracked isFocused = false;
+
+ focusState = modifierFn((element) => {
+ const focusInHandler = () => {
+ this.isFocused = true;
+ };
+ const focusOutHandler = () => {
+ this.isFocused = false;
+ };
+
+ element.addEventListener("focusin", focusInHandler);
+ element.addEventListener("focusout", focusOutHandler);
+
+ return () => {
+ element.removeEventListener("focusin", focusInHandler);
+ element.removeEventListener("focusout", focusOutHandler);
+ };
+ });
+
+ get iconForType() {
+ return this.type === TYPES.password ? "far-eye" : "far-eye-slash";
+ }
+
+ @action
+ handleInput(event) {
+ const value = event.target.value === "" ? undefined : event.target.value;
+ this.args.field.set(value);
+ }
+
+ @action
+ toggleVisibility() {
+ this.type = this.type === TYPES.password ? TYPES.text : TYPES.password;
+ }
+
+
+
+
+
+
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/question.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/question.gjs
new file mode 100644
index 00000000000..dcda7a1eff1
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/question.gjs
@@ -0,0 +1,64 @@
+import Component from "@glimmer/component";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import { eq } from "truth-helpers";
+import FKLabel from "discourse/form-kit/components/fk/label";
+import uniqueId from "discourse/helpers/unique-id";
+import i18n from "discourse-common/helpers/i18n";
+
+export default class FKControlQuestion extends Component {
+ static controlType = "question";
+
+ @action
+ handleInput(event) {
+ this.args.field.set(event.target.value === "true");
+ }
+
+
+
+ {{#let (uniqueId) as |uuid|}}
+
+
+
+ {{#if @yesLabel}}
+ {{@yesLabel}}
+ {{else}}
+ {{i18n "yes_value"}}
+ {{/if}}
+
+ {{/let}}
+
+ {{#let (uniqueId) as |uuid|}}
+
+
+
+ {{#if @noLabel}}
+ {{@noLabel}}
+ {{else}}
+ {{i18n "no_value"}}
+ {{/if}}
+
+ {{/let}}
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/radio-group.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/radio-group.gjs
new file mode 100644
index 00000000000..4071a65c254
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/radio-group.gjs
@@ -0,0 +1,31 @@
+import Component from "@glimmer/component";
+import { hash } from "@ember/helper";
+import FKText from "discourse/form-kit/components/fk/text";
+import FKControlRadioGroupRadio from "./radio-group/radio";
+
+// eslint-disable-next-line ember/no-empty-glimmer-component-classes
+export default class FKControlRadioGroup extends Component {
+ static controlType = "radio-group";
+
+
+
+ {{#if @title}}
+ {{@title}}
+ {{/if}}
+
+ {{#if @subtitle}}
+
+ {{@subtitle}}
+
+ {{/if}}
+
+ {{yield
+ (hash
+ Radio=(component
+ FKControlRadioGroupRadio groupValue=@value field=@field
+ )
+ )
+ }}
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/radio-group/radio.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/radio-group/radio.gjs
new file mode 100644
index 00000000000..7ae2fba4abd
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/radio-group/radio.gjs
@@ -0,0 +1,28 @@
+import { on } from "@ember/modifier";
+import { eq } from "truth-helpers";
+import FKLabel from "discourse/form-kit/components/fk/label";
+import uniqueId from "discourse/helpers/unique-id";
+import withEventValue from "discourse/helpers/with-event-value";
+
+const FKControlRadioGroupRadio =
+ {{#let (uniqueId) as |uuid|}}
+
+
+
+ {{yield}}
+
+
+ {{/let}}
+ ;
+
+export default FKControlRadioGroupRadio;
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/select.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/select.gjs
new file mode 100644
index 00000000000..9b3a304b3b3
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/select.gjs
@@ -0,0 +1,31 @@
+import Component from "@glimmer/component";
+import { hash } from "@ember/helper";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import { NO_VALUE_OPTION } from "discourse/form-kit/lib/constants";
+import FKControlSelectOption from "./select/option";
+
+export default class FKControlSelect extends Component {
+ static controlType = "select";
+
+ @action
+ handleInput(event) {
+ // if an option has no value, event.target.value will be the content of the option
+ // this is why we use this magic value to represent no value
+ this.args.field.set(
+ event.target.value === NO_VALUE_OPTION ? undefined : event.target.value
+ );
+ }
+
+
+
+ {{yield (hash Option=(component FKControlSelectOption selected=@value))}}
+
+
+}
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/select/option.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/select/option.gjs
new file mode 100644
index 00000000000..9604be417ce
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/select/option.gjs
@@ -0,0 +1,33 @@
+import Component from "@glimmer/component";
+import { eq } from "truth-helpers";
+import { NO_VALUE_OPTION } from "discourse/form-kit/lib/constants";
+
+export default class FKControlSelectOption extends Component {
+ get value() {
+ return typeof this.args.value === "undefined"
+ ? NO_VALUE_OPTION
+ : this.args.value;
+ }
+
+
+ {{! https://github.com/emberjs/ember.js/issues/19115 }}
+ {{#if (eq @selected @value)}}
+
+ {{yield}}
+
+ {{else}}
+
+ {{yield}}
+
+ {{/if}}
+
+}
diff --git a/app/assets/javascripts/discourse/app/form-kit/components/fk/control/textarea.gjs b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/textarea.gjs
new file mode 100644
index 00000000000..c54bb48a839
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/form-kit/components/fk/control/textarea.gjs
@@ -0,0 +1,31 @@
+import Component from "@glimmer/component";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import { htmlSafe } from "@ember/template";
+import { escapeExpression } from "discourse/lib/utilities";
+
+export default class FKControlTextarea extends Component {
+ static controlType = "textarea";
+
+ @action
+ handleInput(event) {
+ this.args.field.set(event.target.value);
+ }
+
+ get style() {
+ if (!this.args.height) {
+ return;
+ }
+
+ return `height: ${htmlSafe(escapeExpression(this.args.height) + "px")}`;
+ }
+
+
+
+
+}
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, "-");
+ }
+
+
+ {{#if this.hasErrors}}
+
+ {{/if}}
+
+}
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(", ");
+ }
+
+
+
+
+ {{icon "exclamation-triangle"}}
+ {{this.concatErrors @error.messages}}
+
+
+
+}
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
+
+
+ {{yield}}
+
+
+ ;
+ } else {
+ return
+ {{! template-lint-disable no-yield-only }}
+ {{yield}}
+ ;
+ }
+ }
+
+
+
+ {{yield
+ (hash
+ Code=(component
+ FKControlWrapper
+ errors=@errors
+ component=FKControlCode
+ value=this.value
+ field=this.field
+ format=@format
+ )
+ Question=(component
+ FKControlWrapper
+ errors=@errors
+ component=FKControlQuestion
+ value=this.value
+ field=this.field
+ format=@format
+ )
+ Textarea=(component
+ FKControlWrapper
+ errors=@errors
+ component=FKControlTextarea
+ value=this.value
+ field=this.field
+ format=@format
+ )
+ Checkbox=(component
+ FKControlWrapper
+ errors=@errors
+ component=FKControlCheckbox
+ value=this.value
+ field=this.field
+ format=@format
+ )
+ Image=(component
+ FKControlWrapper
+ errors=@errors
+ component=FKControlImage
+ value=this.value
+ field=this.field
+ format=@format
+ )
+ Password=(component
+ FKControlWrapper
+ errors=@errors
+ component=FKControlPassword
+ value=this.value
+ field=this.field
+ format=@format
+ )
+ Composer=(component
+ FKControlWrapper
+ errors=@errors
+ component=FKControlComposer
+ value=this.value
+ field=this.field
+ format=@format
+ )
+ Icon=(component
+ FKControlWrapper
+ errors=@errors
+ component=FKControlIcon
+ value=this.value
+ field=this.field
+ format=@format
+ )
+ Toggle=(component
+ FKControlWrapper
+ errors=@errors
+ component=FKControlToggle
+ value=this.value
+ field=this.field
+ format=@format
+ )
+ Menu=(component
+ FKControlWrapper
+ errors=@errors
+ component=FKControlMenu
+ value=this.value
+ field=this.field
+ format=@format
+ )
+ Select=(component
+ FKControlWrapper
+ errors=@errors
+ component=FKControlSelect
+ value=this.value
+ field=this.field
+ format=@format
+ )
+ Input=(component
+ FKControlWrapper
+ errors=@errors
+ component=FKControlInput
+ value=this.value
+ field=this.field
+ format=@format
+ )
+ RadioGroup=(component
+ FKControlWrapper
+ errors=@errors
+ component=FKControlRadioGroup
+ value=this.value
+ field=this.field
+ format=@format
+ )
+ errorId=this.field.errorId
+ id=this.field.id
+ name=this.field.name
+ set=this.field.set
+ value=this.value
+ )
+ }}
+
+
+}
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 =
+ {{#each (array @data) as |data|}}
+
+ {{yield components draftData}}
+
+ {{/each}}
+ ;
+
+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 =
+
+ {{yield}}
+
+ ;
+
+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;
+ }
+
+
+ {{#if this.shouldRenderMeta}}
+
+ {{#if @error}}
+
+ {{else if @description}}
+ {{@description}}
+ {{/if}}
+
+ {{#if this.shouldRenderCharCounter}}
+
+ {{/if}}
+
+ {{/if}}
+
+}
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 =
+
+ {{yield (hash Col=FKCol)}}
+
+ ;
+
+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 =
+
+ {{#if @title}}
+
+ {{/if}}
+
+ {{#if @subtitle}}
+ {{@subtitle}}
+ {{/if}}
+
+ {{yield}}
+
+ ;
+
+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 =
+
+ {{yield}}
+
+ ;
+
+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(
+
+
+
+ Remove
+
+
+
+ );
+
+ 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(
+
+
+ Item 1
+ Item 2
+ Item 3
+
+
+
+ );
+
+ 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(
+
+
+ One
+ Two
+ Three
+
+
+
+ );
+
+ 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(
+
+
+ Option 1
+ Option 2
+ Option 3
+
+
+
+ );
+
+ 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(
+
+ Test
+
+
+ );
+
+ 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(
+
+ Test
+
+
+ );
+
+ resetOnerror();
+ });
+
+ test("non existing title", async function (assert) {
+ setupOnerror((error) => {
+ assert.deepEqual(
+ error.message,
+ "@title is required on ` `."
+ );
+ });
+
+ await render(
+
+ Test
+
+
+ );
+
+ 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(
+ Add
+
+
+
+
+
+
+
+ );
+
+ 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(
+ Test
+
+ );
+
+ 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(
+ Test
+
+ );
+
+ assert.dom(".form-kit__alert-message").hasText("Test");
+ });
+
+ test("@icon", async function (assert) {
+ await render(
+ Test
+
+ );
+
+ 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(
+ Test
+
+ );
+
+ 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(
+ Test
+
+ );
+
+ assert
+ .dom(".form-kit__container.something .form-kit__container-content")
+ .hasText("Test");
+ });
+
+ test("@title", async function (assert) {
+ await render(
+ Test
+
+ );
+
+ assert
+ .dom(".form-kit__container .form-kit__container-title")
+ .hasText("Title");
+ });
+
+ test("@subtitle", async function (assert) {
+ await render(
+ Test
+
+ );
+
+ 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(
+
+ Test
+
+
+ );
+
+ assert.dom(".form-kit__row .form-kit__col").hasText("Test");
+ });
+
+ test("@size", async function (assert) {
+ await render(
+
+ Test
+
+
+ );
+
+ 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(
+ Test
+
+ );
+
+ assert.dom(".form-kit__section.something").hasText("Test");
+ });
+
+ test("@title", async function (assert) {
+ await render(
+ Test
+
+ );
+
+ assert
+ .dom(".form-kit__section .form-kit__section-title")
+ .hasText("Title");
+ });
+
+ test("@subtitle", async function (assert) {
+ await render(
+ Test
+
+ );
+
+ 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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-"
- @initialValue={{get @dummy "options.0.name"}}
- as |value|
->
-
-
-
-
-
-
-
-">
-
-
-
-
-
-
-
- and label">
-
- Text:
-
-
-
-
-
-
-
-
-
-
-
-
- and regular button">
-
-
-
-
-
\ No newline at end of file
diff --git a/plugins/styleguide/assets/javascripts/discourse/lib/styleguide.js b/plugins/styleguide/assets/javascripts/discourse/lib/styleguide.js
index 14b64e00d47..d87bb5d34f9 100644
--- a/plugins/styleguide/assets/javascripts/discourse/lib/styleguide.js
+++ b/plugins/styleguide/assets/javascripts/discourse/lib/styleguide.js
@@ -3,7 +3,7 @@ import fontScale from "../components/sections/atoms/01-font-scale";
import buttons from "../components/sections/atoms/02-buttons";
import colors from "../components/sections/atoms/03-colors";
import icons from "../components/sections/atoms/04-icons";
-import inputFields from "../components/sections/atoms/05-input-fields";
+import forms from "../components/sections/atoms/05-forms";
import spinners from "../components/sections/atoms/06-spinners";
import dateTimeInputs from "../components/sections/atoms/date-time-inputs";
import dropdowns from "../components/sections/atoms/dropdowns";
@@ -51,9 +51,9 @@ const SECTIONS = [
{ component: colors, category: "atoms", id: "colors", priority: 3 },
{ component: icons, category: "atoms", id: "icons", priority: 4 },
{
- component: inputFields,
+ component: forms,
category: "atoms",
- id: "input-fields",
+ id: "forms",
priority: 5,
},
{ component: spinners, category: "atoms", id: "spinners", priority: 6 },
diff --git a/plugins/styleguide/config/locales/client.en.yml b/plugins/styleguide/config/locales/client.en.yml
index 9247112fce6..fd6a7b7d2c9 100644
--- a/plugins/styleguide/config/locales/client.en.yml
+++ b/plugins/styleguide/config/locales/client.en.yml
@@ -30,8 +30,8 @@ en:
icons:
title: "Icons"
full_list: "See the full list of Font Awesome Icons"
- input_fields:
- title: "Input Fields"
+ forms:
+ title: "Forms"
buttons:
title: "Buttons"
dropdowns:
diff --git a/spec/system/admin_badges_spec.rb b/spec/system/admin_badges_spec.rb
new file mode 100644
index 00000000000..58b60f4c380
--- /dev/null
+++ b/spec/system/admin_badges_spec.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+describe "Admin Badges Page", type: :system do
+ before { SiteSetting.enable_badges = true }
+
+ fab!(:current_user) { Fabricate(:admin) }
+
+ let(:badges_page) { PageObjects::Pages::AdminBadges.new }
+ let(:form) { PageObjects::Components::FormKit.new("form") }
+
+ before { sign_in(current_user) }
+
+ context "with system badge" do
+ it "displays badge" do
+ badges_page.visit_page(Badge::Autobiographer)
+
+ badge = Badge.find(Badge::Autobiographer)
+
+ expect(form).to have_an_alert(I18n.t("admin_js.admin.badges.disable_system"))
+ expect(form.field("badge_type_id")).to be_disabled
+ expect(form.field("badge_type_id")).to have_value(BadgeType::Bronze.to_s)
+ expect(form.field("badge_grouping_id")).to be_disabled
+ expect(form.field("badge_grouping_id")).to have_value(BadgeGrouping::GettingStarted.to_s)
+ expect(form.field("allow_title")).to be_enabled
+ expect(form.field("allow_title")).to be_unchecked
+ expect(form.field("multiple_grant")).to be_disabled
+ expect(form.field("multiple_grant")).to be_unchecked
+ expect(form.field("listable")).to be_disabled
+ expect(form.field("listable")).to be_checked
+ expect(form.field("show_posts")).to be_disabled
+ expect(form.field("show_posts")).to be_unchecked
+ expect(form.field("icon")).to be_enabled
+ expect(form.field("icon")).to have_value("user-edit")
+ expect(find(".form-kit__container[data-name='name']")).to have_content(badge.name.strip)
+ expect(find(".form-kit__container[data-name='description']")).to have_content(
+ badge.description.strip,
+ )
+ expect(find(".form-kit__container[data-name='long_description']")).to have_content(
+ badge.long_description.strip,
+ )
+ end
+ end
+
+ context "when creating a badge" do
+ it "creates a badge" do
+ badges_page.new_page
+
+ form.field("enabled").accept
+ form.field("name").fill_in("a name")
+ form.field("badge_type_id").select(BadgeType::Bronze)
+ form.field("icon").select("ambulance")
+ form.field("description").fill_in("a description")
+ form.field("long_description").fill_in("a long_description")
+ form.field("badge_grouping_id").select(BadgeGrouping::GettingStarted)
+ form.field("allow_title").toggle
+ form.field("multiple_grant").toggle
+ form.field("listable").toggle
+ form.field("show_posts").toggle
+ form.submit
+
+ expect(PageObjects::Components::Toasts.new).to have_success(I18n.t("js.saved"))
+ expect(badges_page).to have_badge("a name")
+ end
+ end
+
+ context "with enable_badge_sql" do
+ before { SiteSetting.enable_badge_sql = true }
+
+ it "shows the sql section" do
+ badges_page.new_page
+
+ form.field("query").fill_in("a query")
+
+ expect(form.field("auto_revoke")).to be_unchecked
+ expect(form.field("target_posts")).to be_unchecked
+ end
+ end
+end
diff --git a/spec/system/page_objects/admin_badges.rb b/spec/system/page_objects/admin_badges.rb
new file mode 100644
index 00000000000..bc2e96ab144
--- /dev/null
+++ b/spec/system/page_objects/admin_badges.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module PageObjects
+ module Pages
+ class AdminBadges < PageObjects::Pages::Base
+ def visit_page(badge_id = nil)
+ path = "/admin/badges"
+ path += "/#{badge_id}" if badge_id
+ page.visit path
+ self
+ end
+
+ def new_page
+ page.visit "/admin/badges/new"
+ self
+ end
+
+ def has_badge?(title)
+ page.has_css?(".current-badge-header .badge-display-name", text: title)
+ end
+ end
+ end
+end
diff --git a/spec/system/page_objects/components/form_kit.rb b/spec/system/page_objects/components/form_kit.rb
new file mode 100644
index 00000000000..f29c068c813
--- /dev/null
+++ b/spec/system/page_objects/components/form_kit.rb
@@ -0,0 +1,169 @@
+# frozen_string_literal: true
+
+module PageObjects
+ module Components
+ class FormKitField < PageObjects::Components::Base
+ attr_reader :component
+
+ def initialize(input)
+ if input.is_a?(Capybara::Node::Element)
+ @component = input
+ else
+ @component = find(input)
+ end
+ end
+
+ def value
+ case control_type
+ when /input-/, "password"
+ component.find("input").value
+ when "icon"
+ picker = PageObjects::Components::SelectKit.new(component)
+ picker.value
+ when "checkbox"
+ component.find("input[type='checkbox']").checked?
+ when "menu"
+ component.find(".fk-d-menu__trigger")["data-value"]
+ when "select"
+ component.find("select").value
+ end
+ end
+
+ def unchecked?
+ if control_type != "checkbox"
+ raise "'unchecked?' is only supported for control type: #{control_type}"
+ end
+
+ expect(self.value).to eq(false)
+ end
+
+ def checked?
+ if control_type != "checkbox"
+ raise "'checked?' is only supported for control type: #{control_type}"
+ end
+
+ expect(self.value).to eq(true)
+ end
+
+ def has_value?(expected_value)
+ expect(self.value).to eq(expected_value)
+ end
+
+ def control_type
+ component["data-control-type"]
+ end
+
+ def toggle
+ case control_type
+ when "checkbox"
+ component.find("input[type='checkbox']").click
+ when "password"
+ component.find(".form-kit__control-password-toggle").click
+ else
+ raise "'toggle' is not supported for control type: #{control_type}"
+ end
+ end
+
+ def fill_in(value)
+ case control_type
+ when "input-text", "password"
+ component.find("input").fill_in(with: value)
+ when "textarea", "composer"
+ component.find("textarea").fill_in(with: value, visible: :all)
+ when "code"
+ component.find(".ace_text-input", visible: :all).fill_in(with: value)
+ else
+ raise "Unsupported control type: #{control_type}"
+ end
+ end
+
+ def select(value)
+ case control_type
+ when "icon"
+ selector = component.find(".form-kit__control-icon")["id"]
+ picker = PageObjects::Components::SelectKit.new("#" + selector)
+ picker.expand
+ picker.search(value)
+ picker.select_row_by_value(value)
+ when "select"
+ component.find(".form-kit__control-option[value='#{value}']").click
+ when "menu"
+ trigger = component.find(".fk-d-menu__trigger.form-kit__control-menu")
+ trigger.click
+ menu = find("[aria-labelledby='#{trigger["id"]}']")
+ item = menu.find(".form-kit__control-menu-item[data-value='#{value}'] .btn")
+ item.click
+ when "radio-group"
+ radio = component.find("input[type='radio'][value='#{value}']")
+ radio.click
+ when "question"
+ if value == true
+ accept
+ else
+ refuse
+ end
+ else
+ raise "Unsupported control type: #{control_type}"
+ end
+ end
+
+ def accept
+ if control_type == "question"
+ component.find(".form-kit__control-radio[value='true']").click
+ else
+ raise "'accept' is not supported for control type: #{control_type}"
+ end
+ end
+
+ def refuse
+ if control_type == "question"
+ component.find(".form-kit__control-radio[value='false']").click
+ else
+ raise "'accept' is not supported for control type: #{control_type}"
+ end
+ end
+
+ def disabled?
+ component["data-disabled"] == ""
+ end
+
+ def enabled?
+ !disabled?
+ end
+ end
+
+ class FormKit < PageObjects::Components::Base
+ attr_reader :component
+
+ def initialize(component)
+ @component = component
+ end
+
+ def submit
+ page.execute_script(
+ "var form = arguments[0]; form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));",
+ find(component),
+ )
+ end
+
+ def reset
+ page.execute_script(
+ "var form = arguments[0]; form.dispatchEvent(new Event('reset', { bubbles: true, cancelable: true }));",
+ find(component),
+ )
+ end
+
+ def has_an_alert?(message)
+ within component do
+ find(".form-kit__alert-message", text: message)
+ end
+ end
+
+ def field(name)
+ within component do
+ FormKitField.new(find(".form-kit__field[data-name='#{name}']"))
+ end
+ end
+ end
+ end
+end
diff --git a/spec/system/page_objects/components/select_kit.rb b/spec/system/page_objects/components/select_kit.rb
index 65ad55affc2..10ca8d9f73b 100644
--- a/spec/system/page_objects/components/select_kit.rb
+++ b/spec/system/page_objects/components/select_kit.rb
@@ -10,7 +10,11 @@ module PageObjects
end
def component
- find(@context)
+ if @context.is_a?(Capybara::Node::Element)
+ @context
+ else
+ find(@context)
+ end
end
def visible?
@@ -42,6 +46,10 @@ module PageObjects
has_css?(@context + ":not(.disabled)", wait: 0)
end
+ def value
+ component.find(".select-kit-header")["data-value"]
+ end
+
def has_selected_value?(value)
component.find(".select-kit-header[data-value='#{value}']")
end
diff --git a/yarn.lock b/yarn.lock
index 5fad0e999d2..72389318b13 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7824,6 +7824,11 @@ ignore@^5.1.1, ignore@^5.1.8, ignore@^5.2.0, ignore@^5.2.4:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
+immer@^10.1.1:
+ version "10.1.1"
+ resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc"
+ integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==
+
immutable@^4.0.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.0.tgz#eb1738f14ffb39fd068b1dbe1296117484dd34be"