DEV: form-kit

This PR introduces FormKit, a component-based form library designed to simplify form creation and management. This library provides a single `Form` component, various field components, controls, validation mechanisms, and customization options. Additionally, it includes helpers to facilitate testing and writing specifications for forms.

1. **Form Component**:
   - The main component that encapsulates form logic and structure.
   - Yields various utilities like `Field`, `Submit`, `Alert`, etc.

   **Example Usage**:
   ```gjs
   import Form from "discourse/form";

   <template>
     <Form as |form|>
       <form.Field
         @name="username"
         @title="Username"
         @validation="required"
         as |field|
       >
         <field.Input />
       </form.Field>

       <form.Field @name="age" @title="Age" as |field|>
         <field.Input @type="number" />
       </form.Field>

       <form.Submit />
     </Form>
   </template>
   ```

2. **Validation**:
   - Built-in validation rules such as `required`, `number`, `length`, and `url`.
   - Custom validation callbacks for more complex validation logic.

   **Example Usage**:
   ```javascript
   validateUsername(name, value, data, { addError }) {
     if (data.bar / 2 === value) {
       addError(name, "That's not how maths work.");
     }
   }
   ```

   ```hbs
   <form.Field @name="username" @validate={{this.validateUsername}} />
   ```

3. **Customization**:
   - Plugin outlets for extending form functionality.
   - Styling capabilities through propagated attributes.
   - Custom controls with properties provided by `form` and `field`.

   **Example Usage**:
   ```hbs
   <Form class="my-form" as |form|>
     <form.Field class="my-field" as |field|>
       <MyCustomControl id={{field.id}} @onChange={{field.set}} />
     </form.Field>
   </Form>
   ```

4. **Helpers for Testing**:
   - Test assertions for form and field validation.

   **Example usage**:
   ```javascript
   assert.form().hasErrors("the form shows errors");
   assert.form().field("foo").hasValue("bar", "user has set the value");
   ```

   - Helper for interacting with he form

   **Example usage**:
   ```javascript
   await formKit().field("foo").fillIn("bar");
   ```

5. **Page Object for System Specs**:
   - Page objects for interacting with forms in system specs.
   - Methods for submitting forms, checking alerts, and interacting with fields.

   **Example Usage**:
   ```ruby
   form = PageObjects::Components::FormKit.new(".my-form")
   form.submit
   expect(form).to have_an_alert("message")
   ```

   **Field Interactions**:
   ```ruby
   field = form.field("foo")
   expect(field).to have_value("bar")
   field.fill_in("bar")
   ```


6. **Collections handling**:
   - A specific component to handle array of objects

   **Example Usage**:
   ```gjs
    <Form @data={{hash foo=(array (hash bar=1) (hash bar=2))}} as |form|>
      <form.Collection @name="foo" as |collection|>
        <collection.Field @name="bar" @title="Bar" as |field|>
          <field.Input />
        </collection.Field>
      </form.Collection>
    </Form>
   ```
This commit is contained in:
chapoi 2024-07-17 11:59:35 +02:00 committed by GitHub
parent bae492efee
commit 2ca06ba236
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
141 changed files with 6047 additions and 851 deletions

View File

@ -42,7 +42,7 @@ export default class BadgePreview extends Component {
}
get queryPlanHtml() {
let output = `<pre class="badge-query-plan">`;
let output = `<pre>`;
this.args.model.badge.query_plan.forEach((linehash) => {
output += escapeExpression(linehash["QUERY PLAN"]);
output += "<br>";

View File

@ -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);
});
}
}

View File

@ -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 = [];

View File

@ -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");
}
}
}

View File

@ -2,14 +2,24 @@
<div class="badges-header">
<h3 class="badges-heading">{{i18n "admin.badges.title"}}</h3>
<div class="create-new-badge">
<LinkTo @route="adminBadges.show" @model="new" class="btn btn-primary">
{{d-icon "plus"}}
<span>{{i18n "admin.badges.new"}}</span>
</LinkTo>
<LinkTo @route="adminBadges.award" @model="new" class="btn btn-default">
{{d-icon "upload"}}
<span>{{i18n "admin.badges.mass_award.title"}}</span>
<span class="d-button-label">{{i18n
"admin.badges.mass_award.title"
}}</span>
</LinkTo>
<DButton
@action={{route-action "editGroupings"}}
@translatedLabel="Group settings"
@icon="cog"
@class="btn-default"
/>
<LinkTo @route="adminBadges.show" @model="new" class="btn btn-primary">
{{d-icon "plus"}}
<span>{{i18n "admin.badges.new"}}</span>
</LinkTo>
</div>
</div>

View File

@ -1,311 +1,328 @@
<section class="current-badge content-body">
<div class="control-group current-badge__toggle-badge">
<DToggleSwitch
@state={{this.buffered.enabled}}
@label={{this.badgeEnabledLabel}}
{{on "click" this.toggleBadge}}
/>
</div>
<Form
@data={{this.formData}}
@onSubmit={{this.handleSubmit}}
@validate={{this.validateForm}}
class="badge-form current-badge content-body"
as |form data|
>
<form class="form-horizontal">
<div class="control-group">
<label for="name">{{i18n "admin.badges.name"}}</label>
{{#if this.readOnly}}
<Input
@type="text"
name="name"
@value={{this.buffered.name}}
disabled={{true}}
/>
<p class="help">
<LinkTo
@route="adminSiteText"
@query={{hash q=(concat this.textCustomizationPrefix "name")}}
>
{{i18n "admin.badges.read_only_setting_help"}}
</LinkTo>
</p>
{{else}}
<Input @type="text" name="name" @value={{this.buffered.name}} />
{{/if}}
</div>
<h1 class="current-badge-header">
{{iconOrImage data}}
<span class="badge-display-name">{{data.name}}</span>
</h1>
<div class="control-group">
<label for="graphic">{{i18n "admin.badges.graphic"}}</label>
<div class="radios inline-form full-width">
<label class="radio-label" for="badge-icon">
<RadioButton
@name="badge-icon"
@id="badge-icon"
@value="icon"
@selection={{this.selectedGraphicType}}
@onChange={{action "changeGraphicType"}}
/>
<span>{{i18n "admin.badges.select_an_icon"}}</span>
</label>
<label class="radio-label" for="badge-image">
<RadioButton
@name="badge-image"
@id="badge-image"
@value="image"
@selection={{this.selectedGraphicType}}
@onChange={{action "changeGraphicType"}}
/>
<span>{{i18n "admin.badges.upload_an_image"}}</span>
</label>
</div>
{{#if this.imageUploaderSelected}}
<UppyImageUploader
@id="badge-image-uploader"
@imageUrl={{this.buffered.image_url}}
@type="badge_image"
@onUploadDone={{action "setImage"}}
@onUploadDeleted={{action "removeImage"}}
class="no-repeat contain-image"
/>
<div class="control-instructions">
<p class="help">{{i18n "admin.badges.image_help"}}</p>
</div>
{{else if this.iconSelectorSelected}}
<IconPicker
@name="icon"
@value={{this.buffered.icon}}
@options={{hash maximum=1}}
@onChange={{fn (mut this.buffered.icon)}}
/>
{{/if}}
</div>
<div class="control-group">
<label for="badge_type_id">{{i18n "admin.badges.badge_type"}}</label>
<ComboBox
@name="badge_type_id"
@value={{this.buffered.badge_type_id}}
@content={{this.badgeTypes}}
@onChange={{fn (mut this.buffered.badge_type_id)}}
@options={{hash disabled=this.readOnly}}
{{#if this.readOnly}}
<form.Alert @icon="info-circle">
{{i18n "admin.badges.disable_system"}}
</form.Alert>
{{else}}
<form.Field
@name="enabled"
@disabled={{this.readOnly}}
@validation="required"
@title={{i18n "admin.badges.status"}}
as |field|
>
<field.Question
@yesLabel={{i18n "admin.badges.enabled"}}
@noLabel={{i18n "admin.badges.disabled"}}
/>
</div>
</form.Field>
{{/if}}
<div class="control-group">
<label for="badge_grouping_id">{{i18n
"admin.badges.badge_grouping"
}}</label>
{{#if this.readOnly}}
<form.Container data-name="name" @title={{i18n "admin.badges.name"}}>
<span class="readonly-field">
{{this.model.name}}
</span>
<LinkTo
@route="adminSiteText.edit"
@models={{array (concat this.textCustomizationPrefix "name")}}
@query={{hash locale="en"}}
>
{{d-icon "pencil-alt"}}
</LinkTo>
</form.Container>
{{else}}
<form.Field
@title={{i18n "admin.badges.name"}}
@name="name"
@disabled={{this.readOnly}}
@validation="required"
as |field|
>
<field.Input />
</form.Field>
{{/if}}
<div class="badge-grouping-control">
<ComboBox
@name="badge_grouping_id"
@value={{this.buffered.badge_grouping_id}}
@content={{this.badgeGroupings}}
class="badge-selector"
@nameProperty="name"
@onChange={{fn (mut this.buffered.badge_grouping_id)}}
/>
<DButton
@action={{route-action "editGroupings"}}
@icon="pencil-alt"
class="btn-default"
/>
</div>
</div>
<form.Section @title="Design">
<form.Field
@name="badge_type_id"
@title={{i18n "admin.badges.badge_type"}}
@disabled={{this.readOnly}}
as |field|
>
<field.Select as |select|>
{{#each this.badgeTypes as |badgeType|}}
<select.Option @value={{badgeType.id}}>
{{badgeType.name}}
</select.Option>
{{/each}}
</field.Select>
</form.Field>
<div class="control-group">
<label for="description">{{i18n "admin.badges.description"}}</label>
{{#if this.buffered.system}}
<Textarea
name="description"
@value={{this.buffered.description}}
disabled={{true}}
/>
<p class="help">
<LinkTo
@route="adminSiteText"
@query={{hash
q=(concat this.textCustomizationPrefix "description")
}}
<form.ConditionalContent
@activeName={{if data.image "upload-image" "choose-icon"}}
as |cc|
>
<cc.Conditions as |Condition|>
<Condition @name="choose-icon">
{{i18n "admin.badges.select_an_icon"}}
</Condition>
<Condition @name="upload-image">
{{i18n "admin.badges.upload_an_image"}}
</Condition>
</cc.Conditions>
<cc.Contents as |Content|>
<Content @name="choose-icon">
<form.Field
@title={{i18n "admin.badges.icon"}}
@showTitle={{false}}
@name="icon"
@onSet={{this.onSetIcon}}
as |field|
>
{{i18n "admin.badges.read_only_setting_help"}}
</LinkTo>
</p>
{{else}}
<Textarea name="description" @value={{this.buffered.description}} />
{{/if}}
</div>
<div class="control-group">
<label for="long_description">{{i18n
"admin.badges.long_description"
}}</label>
{{#if this.buffered.system}}
<Textarea
name="long_description"
@value={{this.buffered.long_description}}
disabled={{true}}
/>
<p class="help">
<LinkTo
@route="adminSiteText"
@query={{hash
q=(concat this.textCustomizationPrefix "long_description")
}}
<field.Icon />
</form.Field>
</Content>
<Content @name="upload-image">
<form.Field
@name="image"
@showTitle={{false}}
@title={{i18n "admin.badges.image"}}
@onSet={{this.onSetImage}}
@onUnset={{this.onUnsetImage}}
as |field|
>
{{i18n "admin.badges.read_only_setting_help"}}
</LinkTo>
</p>
{{else}}
<Textarea
name="long_description"
@value={{this.buffered.long_description}}
/>
{{/if}}
</div>
<field.Image />
</form.Field>
</Content>
</cc.Contents>
</form.ConditionalContent>
{{#if this.siteSettings.enable_badge_sql}}
<div class="control-group">
<label for="query">{{i18n "admin.badges.query"}}</label>
<AceEditor
@content={{this.buffered.query}}
@mode="sql"
@disabled={{this.readOnly}}
/>
</div>
{{#if this.hasQuery}}
<a
href
{{on "click" (fn this.showPreview this.buffered "false")}}
class="preview-badge"
{{#if this.readOnly}}
<form.Container
data-name="description"
@title={{i18n "admin.badges.description"}}
>
<span class="readonly-field">
{{this.model.description}}
</span>
<LinkTo
@route="adminSiteText.edit"
@models={{array (concat this.textCustomizationPrefix "description")}}
@query={{hash locale="en"}}
>
{{i18n "admin.badges.preview.link_text"}}</a>
|
<a
href
{{on "click" (fn this.showPreview this.buffered "true")}}
class="preview-badge-plan"
>
{{i18n "admin.badges.preview.plan_text"}}
</a>
{{#if this.preview_loading}}
{{i18n "loading"}}
{{/if}}
<div class="control-group">
<label>
<Input
name="auto_revoke"
@type="checkbox"
@checked={{this.buffered.auto_revoke}}
disabled={{this.readOnly}}
/>
{{i18n "admin.badges.auto_revoke"}}
</label>
</div>
<div class="control-group">
<label>
<Input
name="target_posts"
@type="checkbox"
@checked={{this.buffered.target_posts}}
disabled={{this.readOnly}}
/>
{{i18n "admin.badges.target_posts"}}
</label>
</div>
<div class="control-group">
<label for="trigger">{{i18n "admin.badges.trigger"}}</label>
<ComboBox
name="trigger"
@value={{this.buffered.trigger}}
@content={{this.badgeTriggers}}
@onChange={{fn (mut this.buffered.trigger)}}
@options={{hash disabled=this.readOnly}}
/>
</div>
{{/if}}
{{d-icon "pencil-alt"}}
</LinkTo>
</form.Container>
{{else}}
<form.Field
@title={{i18n "admin.badges.description"}}
@name="description"
@disabled={{this.readOnly}}
as |field|
>
<field.Textarea />
</form.Field>
{{/if}}
<div class="control-group">
<div>
<label class="checkbox-label">
<Input @type="checkbox" @checked={{this.buffered.allow_title}} />
{{i18n "admin.badges.allow_title"}}
</label>
</div>
{{#if this.readOnly}}
<form.Container
data-name="long_description"
@title={{i18n "admin.badges.long_description"}}
>
<span class="readonly-field">
{{this.model.long_description}}
</span>
<div>
<label class="checkbox-label">
<Input
@type="checkbox"
@checked={{this.buffered.multiple_grant}}
disabled={{this.readOnly}}
/>
{{i18n "admin.badges.multiple_grant"}}
</label>
</div>
<LinkTo
@route="adminSiteText.edit"
@models={{array
(concat this.textCustomizationPrefix "long_description")
}}
@query={{hash locale="en"}}
>
{{d-icon "pencil-alt"}}
</LinkTo>
</form.Container>
{{else}}
<form.Field
@name="long_description"
@title={{i18n "admin.badges.long_description"}}
@disabled={{this.readOnly}}
as |field|
>
<field.Textarea />
</form.Field>
{{/if}}
</form.Section>
<div>
<label class="checkbox-label">
<Input
@type="checkbox"
@checked={{this.buffered.listable}}
disabled={{this.readOnly}}
/>
{{i18n "admin.badges.listable"}}
</label>
</div>
{{#if this.siteSettings.enable_badge_sql}}
<form.Section @title="Query">
<form.Field
@name="query"
@title={{i18n "admin.badges.query"}}
@disabled={{this.readOnly}}
as |field|
>
<field.Code @lang="sql" />
</form.Field>
<div>
<label class="checkbox-label">
<Input
@type="checkbox"
@checked={{this.buffered.show_posts}}
disabled={{this.readOnly}}
{{#if (this.hasQuery data.query)}}
<form.Container>
<form.Button
@isLoading={{this.preview_loading}}
@label="admin.badges.preview.link_text"
class="preview-badge"
@action={{fn this.showPreview data "false"}}
/>
{{i18n "admin.badges.show_posts"}}
</label>
<form.Button
@isLoading={{this.preview_loading}}
@label="admin.badges.preview.plan_text"
class="preview-badge-plan"
@action={{fn this.showPreview data "true"}}
/>
</form.Container>
<form.Field
@name="auto_revoke"
@disabled={{this.readOnly}}
@showTitle={{false}}
@title={{i18n "admin.badges.auto_revoke_label"}}
as |field|
>
<field.Checkbox>
{{i18n "admin.badges.auto_revoke"}}
</field.Checkbox>
</form.Field>
<form.Field
@name="target_posts"
@disabled={{this.readOnly}}
@title={{i18n "admin.badges.target_posts_label"}}
@showTitle={{false}}
as |field|
>
<field.Checkbox>
{{i18n "admin.badges.target_posts"}}
</field.Checkbox>
</form.Field>
<form.Field
@name="trigger"
@disabled={{this.readOnly}}
@validation="required"
@title={{i18n "admin.badges.trigger"}}
as |field|
>
<field.Select as |select|>
{{#each this.badgeTriggers as |badgeType|}}
<select.Option @value={{badgeType.id}}>
{{badgeType.name}}
</select.Option>
{{/each}}
</field.Select>
</form.Field>
{{/if}}
</form.Section>
{{/if}}
<form.Section @title="Settings">
<form.Field
@name="badge_grouping_id"
@disabled={{this.readOnly}}
@validation="required"
@title={{i18n "admin.badges.badge_grouping"}}
as |field|
>
<field.Menu @selection={{this.currentBadgeGrouping data}} as |menu|>
{{#each this.badgeGroupings as |grouping|}}
<menu.Item @value={{grouping.id}}>{{grouping.name}}</menu.Item>
{{/each}}
<menu.Divider />
<menu.Item @action={{route-action "editGroupings"}}>Add new group</menu.Item>
</field.Menu>
</form.Field>
<form.Field
@title={{i18n "admin.badges.allow_title_label"}}
@showTitle={{false}}
@name="allow_title"
as |field|
>
<field.Checkbox>{{i18n "admin.badges.allow_title"}}</field.Checkbox>
</form.Field>
<form.Field
@title={{i18n "admin.badges.multiple_grant_label"}}
@showTitle={{false}}
@name="multiple_grant"
@disabled={{this.readOnly}}
as |field|
>
<field.Checkbox>{{i18n "admin.badges.multiple_grant"}}</field.Checkbox>
</form.Field>
<form.Field
@title={{i18n "admin.badges.listable_label"}}
@showTitle={{false}}
@name="listable"
@disabled={{this.readOnly}}
as |field|
>
<field.Checkbox>{{i18n "admin.badges.listable"}}</field.Checkbox>
</form.Field>
<form.Field
@title={{i18n "admin.badges.show_posts_label"}}
@showTitle={{false}}
@name="show_posts"
@disabled={{this.readOnly}}
as |field|
>
<field.Checkbox>{{i18n "admin.badges.show_posts"}}</field.Checkbox>
</form.Field>
</form.Section>
<PluginOutlet
@name="admin-above-badge-buttons"
@outletArgs={{hash badge=this.buffered form=form}}
/>
<form.Actions>
<form.Submit />
{{#unless this.readOnly}}
<form.Button @action={{this.handleDelete}} class="btn-danger">
{{i18n "admin.badges.delete"}}
</form.Button>
{{/unless}}
</form.Actions>
{{#if this.grant_count}}
<div class="content-body current-badge-actions">
<div>
<LinkTo @route="badges.show" @model={{this}}>
{{html-safe
(i18n
"badges.awarded"
count=this.displayCount
number=(number this.displayCount)
)
}}
</LinkTo>
</div>
</div>
<PluginOutlet
@name="admin-above-badge-buttons"
@outletArgs={{hash badge=this.buffered}}
/>
<div class="buttons">
<DButton
@action={{this.save}}
@disabled={{this.saving}}
@label="admin.badges.save"
type="submit"
class="btn-primary"
/>
<span class="saving">{{this.savingStatus}}</span>
{{#unless this.readOnly}}
<DButton
@action={{this.destroyBadge}}
@label="admin.badges.delete"
class="btn-danger"
/>
{{/unless}}
</div>
</form>
</section>
{{#if this.grant_count}}
<div class="content-body current-badge-actions">
<div>
<LinkTo @route="badges.show" @model={{this}}>
{{html-safe
(i18n
"badges.awarded"
count=this.displayCount
number=(number this.displayCount)
)
}}
</LinkTo>
</div>
</div>
{{/if}}
{{/if}}
</Form>

View File

@ -1,5 +1,5 @@
{{#if this.isLoading}}
{{loading-spinner size="small"}}
{{else}}
<div class="ace">{{this.content}}</div>
<div class="ace" ...attributes>{{this.content}}</div>
{{/if}}

View File

@ -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({

View File

@ -67,6 +67,7 @@
@focusIn={{action "focusIn"}}
@focusOut={{action "focusOut"}}
class="d-editor-input"
@id={{this.textAreaId}}
/>
<PopupInputTip @validation={{this.validation}} />
<PluginOutlet

View File

@ -5,7 +5,7 @@ import I18n from "discourse-i18n";
export default class DToggleSwitch extends Component {
<template>
<div class="d-toggle-switch">
<label class="d-toggle-switch--label">
<label class="d-toggle-switch__label">
{{! template-lint-disable no-redundant-role }}
<button
class="d-toggle-switch__checkbox"
@ -23,9 +23,11 @@ export default class DToggleSwitch extends Component {
</span>
</label>
<span class="d-toggle-switch__checkbox-label">
{{this.computedLabel}}
</span>
{{#if this.computedLabel}}
<span class="d-toggle-switch__checkbox-label">
{{this.computedLabel}}
</span>
{{/if}}
</div>
</template>

View File

@ -0,0 +1,3 @@
import Form from "discourse/form-kit/components/fk/form";
export default Form;

View File

@ -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";
}
<template>
<div class="form-kit__alert alert alert-{{this.type}}" ...attributes>
{{#if @icon}}
{{icon @icon}}
{{/if}}
<span class="form-kit__alert-message">{{yield}}</span>
</div>
</template>
}

View File

@ -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;
}
<template>
<span
class={{concatClass
"form-kit__char-counter"
(if (gt this.currentLength @maxLength) "--exceeded")
(if (lt this.currentLength @minLength) "--insufficient")
}}
...attributes
>
{{this.currentLength}}/{{@maxLength}}
</span>
</template>
}

View File

@ -0,0 +1,10 @@
import { concat } from "@ember/helper";
import concatClass from "discourse/helpers/concat-class";
const FKCol = <template>
<div class={{concatClass "form-kit__col" (if @size (concat "--col-" @size))}}>
{{yield}}
</div>
</template>;
export default FKCol;

View File

@ -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);
}
<template>
<div class="form-kit__colection">
{{#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}}
</div>
</template>
}

View File

@ -0,0 +1,22 @@
import FormText from "discourse/form-kit/components/fk/text";
import concatClass from "discourse/helpers/concat-class";
const FKContainer = <template>
<div class={{concatClass "form-kit__container" @class}} ...attributes>
{{#if @title}}
<span class="form-kit__container-title">
{{@title}}
</span>
{{/if}}
{{#if @subtitle}}
<FormText class="form-kit__container-subtitle">{{@subtitle}}</FormText>
{{/if}}
<div class="form-kit__container-content">
{{yield}}
</div>
</div>
</template>;
export default FKContainer;

View File

@ -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, "-");
}
<template>
<div
id={{concat "control-" (this.normalizeName @field.name)}}
class={{concatClass
"form-kit__container"
"form-kit__field"
(concat "form-kit__field-" this.controlType)
(if this.error "has-error")
}}
data-disabled={{@field.disabled}}
data-name={{@field.name}}
data-control-type={{this.controlType}}
>
{{#if @field.showTitle}}
<FKLabel class="form-kit__container-title" @fieldId={{@field.id}}>
{{@field.title}}
{{#unless @field.required}}
<span class="form-kit__container-optional">({{i18n
"form_kit.optional"
}})</span>
{{/unless}}
</FKLabel>
{{/if}}
{{#if @field.subtitle}}
<FormText
class="form-kit__container-subtitle"
>{{@field.subtitle}}</FormText>
{{/if}}
<div
class={{concatClass
"form-kit__container-content"
(if @format (concat "--" @format))
}}
>
<@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>
<FKMeta
@description={{@description}}
@value={{@value}}
@field={{@field}}
@error={{this.error}}
/>
</div>
</div>
</template>
}

View File

@ -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);
}
<template>
<FKLabel class="form-kit__control-checkbox-label">
<input
type="checkbox"
checked={{eq @value true}}
class="form-kit__control-checkbox"
disabled={{@field.disabled}}
...attributes
{{on "change" this.handleInput}}
/>
<span>{{yield}}</span>
</FKLabel>
</template>
}

View File

@ -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")}`;
}
<template>
<AceEditor
@content={{readonly this.initialValue}}
@mode={{@lang}}
@disabled={{@field.disabled}}
@onChange={{this.handleInput}}
class="form-kit__control-code"
style={{this.style}}
...attributes
/>
</template>
}

View File

@ -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")}`;
}
<template>
<DEditor
@value={{readonly @value}}
@change={{this.handleInput}}
@disabled={{@field.disabled}}
class="form-kit__control-composer"
style={{this.style}}
@textAreaId={{@field.id}}
/>
</template>
}

View File

@ -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 = <template>
<div class="form-kit__inline-radio">
{{yield
(component
FKControlConditionalDisplayCondition
activeName=@activeName
setCondition=@setCondition
)
}}
</div>
</template>;
const Contents = <template>
{{yield
(component FKControlConditionalContentContent activeName=@activeName)
}}
</template>;
export default class FKControlConditionalContent extends Component {
@tracked activeName = this.args.activeName;
@action
setCondition(name) {
this.activeName = name;
}
<template>
<div class="form-kit__conditional-display">
{{yield
(hash
Conditions=(component
Conditions activeName=this.activeName setCondition=this.setCondition
)
Contents=(component Contents activeName=this.activeName)
)
}}
</div>
</template>
}

View File

@ -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 = <template>
{{#let (uniqueId) as |uuid|}}
<FKLabel @fieldId={{uuid}} class="form-kit__control-radio-label">
<input
type="radio"
id={{uuid}}
value={{@name}}
checked={{eq @name @activeName}}
class="form-kit__control-radio"
{{on "change" (fn @setCondition @name)}}
/>
<span>{{yield}}</span>
</FKLabel>
{{/let}}
</template>;
export default FKControlConditionalContentOption;

View File

@ -0,0 +1,11 @@
import { eq } from "truth-helpers";
const FKControlConditionalContentItem = <template>
{{#if (eq @name @activeName)}}
<div class="form-kit__conditional-display-content">
{{yield}}
</div>
{{/if}}
</template>;
export default FKControlConditionalContentItem;

View File

@ -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);
}
<template>
<IconPicker
@value={{readonly @value}}
@onlyAvailable={{true}}
@options={{hash
maximum=1
disabled=@field.disabled
caretDownIcon="angle-down"
caretUpIcon="angle-up"
icons=@value
}}
@onChange={{this.handleInput}}
class="form-kit__control-icon"
/>
</template>
}

View File

@ -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);
}
<template>
<UppyImageUploader
@id={{concat @field.id "-" @field.name}}
@imageUrl={{readonly @value}}
@onUploadDone={{this.setImage}}
@onUploadDeleted={{this.removeImage}}
class="form-kit__control-image no-repeat contain-image"
/>
</template>
}

View File

@ -0,0 +1,25 @@
import { hash } from "@ember/helper";
import FKField from "discourse/form-kit/components/fk/field";
const FKControlInputGroup = <template>
<div class="form-kit__input-group">
{{yield
(hash
Field=(component
FKField
errors=@errors
addError=@addError
data=@data
set=@set
remove=@remove
registerField=@registerField
unregisterField=@unregisterField
triggerRevalidationFor=@triggerRevalidationFor
showMeta=false
)
)
}}
</div>
</template>;
export default FKControlInputGroup;

View File

@ -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);
}
<template>
<div class="form-kit__control-input-wrapper">
{{#if @before}}
<span class="form-kit__before-input">{{@before}}</span>
{{/if}}
<input
type={{this.type}}
value={{@value}}
class={{concatClass
"form-kit__control-input"
(if @before "has-prefix")
(if @after "has-suffix")
}}
disabled={{@field.disabled}}
...attributes
{{on "input" this.handleInput}}
/>
{{#if @after}}
<span class="form-kit__after-input">{{@after}}</span>
{{/if}}
</div>
</template>
}

View File

@ -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;
}
<template>
<DMenu
@onRegisterApi={{this.registerMenuApi}}
@triggerClass="form-kit__control-menu"
@disabled={{@field.disabled}}
@placement="bottom-start"
@offset={{5}}
id={{@field.id}}
data-value={{@value}}
@modalForMobile={{true}}
>
<:trigger>
<span class="d-button-label">
{{@selection}}
</span>
{{icon "angle-down"}}
</:trigger>
<:content>
<DropdownMenu as |menu|>
{{yield
(hash
Item=(component
FKControlMenuItem
item=menu.item
field=@field
menuApi=this.menuApi
)
Divider=(component FKControlMenuDivider divider=menu.divider)
Container=FKControlMenuContainer
)
}}
</DropdownMenu>
</:content>
</DMenu>
</template>
}

View File

@ -0,0 +1,7 @@
const FKControlMenuContainer = <template>
<li class="form-kit__control-menu-container">
{{yield}}
</li>
</template>;
export default FKControlMenuContainer;

View File

@ -0,0 +1,5 @@
const FKControlMenuDivider = <template>
<@divider class="form-kit__control-menu-divider" />
</template>;
export default FKControlMenuDivider;

View File

@ -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);
}
}
<template>
<@item class="form-kit__control-menu-item" data-value={{@value}}>
<DButton
@action={{this.handleInput}}
class="btn-flat"
@icon={{@icon}}
...attributes
>
{{yield}}
</DButton>
</@item>
</template>
}

View File

@ -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;
}
<template>
<div
class={{concatClass
"form-kit__control-password-wrapper"
(if this.isFocused "--focused")
}}
>
<input
type={{this.type}}
value={{@value}}
class="form-kit__control-password"
disabled={{@field.disabled}}
...attributes
{{on "input" this.handleInput}}
{{this.focusState}}
/>
<DButton
class="btn-transparent form-kit__control-password-toggle"
@action={{this.toggleVisibility}}
@icon={{this.iconForType}}
role="switch"
aria-checked={{eq this.type TYPES.text}}
/>
</div>
</template>
}

View File

@ -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");
}
<template>
<div class="form-kit__inline-radio">
{{#let (uniqueId) as |uuid|}}
<FKLabel @fieldId={{uuid}} class="form-kit__control-radio-label --yes">
<input
name={{@field.name}}
type="radio"
value="true"
checked={{eq @value true}}
class="form-kit__control-radio"
disabled={{@field.disabled}}
...attributes
id={{uuid}}
{{on "change" this.handleInput}}
/>
{{#if @yesLabel}}
{{@yesLabel}}
{{else}}
{{i18n "yes_value"}}
{{/if}}
</FKLabel>
{{/let}}
{{#let (uniqueId) as |uuid|}}
<FKLabel @fieldId={{uuid}} class="form-kit__control-radio-label --no">
<input
name={{@field.name}}
type="radio"
value="false"
checked={{eq @value false}}
class="form-kit__control-radio"
disabled={{@field.disabled}}
...attributes
id={{uuid}}
{{on "change" this.handleInput}}
/>
{{#if @noLabel}}
{{@noLabel}}
{{else}}
{{i18n "no_value"}}
{{/if}}
</FKLabel>
{{/let}}
</div>
</template>
}

View File

@ -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";
<template>
<fieldset class="form-kit__radio-group" ...attributes>
{{#if @title}}
<legend class="form-kit__radio-group-title">{{@title}}</legend>
{{/if}}
{{#if @subtitle}}
<FKText class="form-kit__radio-group-subtitle">
{{@subtitle}}
</FKText>
{{/if}}
{{yield
(hash
Radio=(component
FKControlRadioGroupRadio groupValue=@value field=@field
)
)
}}
</fieldset>
</template>
}

View File

@ -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 = <template>
{{#let (uniqueId) as |uuid|}}
<div class="form-kit__field form-kit__field-radio">
<FKLabel @fieldId={{uuid}} class="form-kit__control-radio-label">
<input
name={{@field.name}}
type="radio"
value={{@value}}
checked={{eq @groupValue @value}}
id={{uuid}}
class="form-kit__control-radio"
disabled={{@field.disabled}}
...attributes
{{on "change" (withEventValue @field.set)}}
/>
<span>{{yield}}</span>
</FKLabel>
</div>
{{/let}}
</template>;
export default FKControlRadioGroupRadio;

View File

@ -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
);
}
<template>
<select
value={{@value}}
disabled={{@field.disabled}}
...attributes
class="form-kit__control-select"
{{on "input" this.handleInput}}
>
{{yield (hash Option=(component FKControlSelectOption selected=@value))}}
</select>
</template>
}

View File

@ -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;
}
<template>
{{! https://github.com/emberjs/ember.js/issues/19115 }}
{{#if (eq @selected @value)}}
<option
class="form-kit__control-option --selected"
value={{this.value}}
selected
...attributes
>
{{yield}}
</option>
{{else}}
<option
class="form-kit__control-option"
value={{this.value}}
...attributes
>
{{yield}}
</option>
{{/if}}
</template>
}

View File

@ -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")}`;
}
<template>
<textarea
class="form-kit__control-textarea"
style={{this.style}}
...attributes
{{on "input" this.handleInput}}
>{{@value}}</textarea>
</template>
}

View File

@ -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);
}
<template>
<DToggleSwitch
@state={{@value}}
{{on "click" this.handleInput}}
class="form-kit__control-toggle"
/>
</template>
}

View File

@ -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, "-");
}
<template>
{{#if this.hasErrors}}
<div class="form-kit__errors-summary" aria-live="assertive" ...attributes>
<h2 class="form-kit__errors-summary-title">
{{icon "exclamation-triangle"}}
{{i18n "form_kit.errors_summary_title"}}
</h2>
<ul class="form-kit__errors-summary-list">
{{#each-in @errors as |name error|}}
<li>
<a
rel="noopener noreferrer"
href={{concat "#control-" (this.normalizeName name)}}
>{{error.title}}</a>:
{{this.concatErrors error.messages}}
</li>
{{/each-in}}
</ul>
</div>
{{/if}}
</template>
}

View File

@ -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(", ");
}
<template>
<p class="form-kit__errors" id={{@id}} aria-live="assertive" ...attributes>
<span>
{{icon "exclamation-triangle"}}
{{this.concatErrors @error.messages}}
</span>
</p>
</template>
}

View File

@ -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 `<form.Field />`.");
}
if (typeof this.args.name !== "string") {
throw new Error(
"@name is required and must be a string on `<form.Field />`."
);
}
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 <template>
<FKRow as |row|>
<row.Col @size={{@size}}>
{{yield}}
</row.Col>
</FKRow>
</template>;
} else {
return <template>
{{! template-lint-disable no-yield-only }}
{{yield}}
</template>;
}
}
<template>
<this.wrapper @size={{@size}}>
{{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
)
}}
</this.wrapper>
</template>
}

View File

@ -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 `<form.Field />`.");
}
if (this.fields.has(name)) {
throw new Error(
`@name="${name}", is already in use. Names of \`<form.Field />\` 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;
}
}
<template>
<form
novalidate
class="form-kit"
...attributes
{{on "submit" this.onSubmit}}
{{on "reset" this.onReset}}
>
<FKErrorsSummary @errors={{this.formData.errors}} />
{{yield
(hash
Row=Row
Section=FKSection
ConditionalContent=(component FKControlConditionalContent)
Container=FKContainer
Actions=(component FKSection class="form-kit__actions")
Button=(component DButton class="form-kit__button")
Alert=FKAlert
Submit=(component
DButton
action=this.onSubmit
forwardEvent=true
class="btn-primary form-kit__button"
label="submit"
type="submit"
isLoading=this.isSubmitting
)
Reset=(component
DButton
action=this.onReset
forwardEvent=true
class="form-kit__button"
label="form_kit.reset"
)
Field=(component
FKField
errors=this.formData.errors
addError=this.addError
data=this.formData
set=this.set
registerField=this.registerField
unregisterField=this.unregisterField
triggerRevalidationFor=this.triggerRevalidationFor
)
Collection=(component
FKCollection
errors=this.formData.errors
addError=this.addError
data=this.formData
set=this.set
remove=this.remove
registerField=this.registerField
unregisterField=this.unregisterField
triggerRevalidationFor=this.triggerRevalidationFor
)
InputGroup=(component
FKControlInputGroup
errors=this.formData.errors
addError=this.addError
data=this.formData
set=this.set
remove=this.remove
registerField=this.registerField
unregisterField=this.unregisterField
triggerRevalidationFor=this.triggerRevalidationFor
)
set=this.set
addItemToCollection=this.addItemToCollection
)
this.formData.draftData
}}
</form>
</template>
}
const Form = <template>
{{#each (array @data) as |data|}}
<FKForm
@data={{data}}
@onSubmit={{@onSubmit}}
@validate={{@validate}}
@validateOn={{@validateOn}}
@onRegisterApi={{@onRegisterApi}}
@onReset={{@onReset}}
...attributes
as |components draftData|
>
{{yield components draftData}}
</FKForm>
{{/each}}
</template>;
export default Form;

View File

@ -0,0 +1,7 @@
const FKLabel = <template>
<label for={{@fieldId}} ...attributes>
{{yield}}
</label>
</template>;
export default FKLabel;

View File

@ -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;
}
<template>
{{#if this.shouldRenderMeta}}
<div class="form-kit__meta">
{{#if @error}}
<FKErrors @id={{@field.errorId}} @error={{@error}} />
{{else if @description}}
<FKText class="form-kit__meta-description">{{@description}}</FKText>
{{/if}}
{{#if this.shouldRenderCharCounter}}
<FKCharCounter
@value={{@value}}
@minLength={{@field.minLength}}
@maxLength={{@field.maxLength}}
/>
{{/if}}
</div>
{{/if}}
</template>
}

View File

@ -0,0 +1,10 @@
import { hash } from "@ember/helper";
import FKCol from "discourse/form-kit/components/fk/col";
const FKRow = <template>
<div class="form-kit__row" ...attributes>
{{yield (hash Col=FKCol)}}
</div>
</template>;
export default FKRow;

View File

@ -0,0 +1,17 @@
import concatClass from "discourse/helpers/concat-class";
const FKSection = <template>
<div class={{concatClass "form-kit__section" @class}} ...attributes>
{{#if @title}}
<h2 class="form-kit__section-title">{{@title}}</h2>
{{/if}}
{{#if @subtitle}}
<span class="form-kit__section-subtitle">{{@subtitle}}</span>
{{/if}}
{{yield}}
</div>
</template>;
export default FKSection;

View File

@ -0,0 +1,7 @@
const FKText = <template>
<p class="form-kit-text" ...attributes>
{{yield}}
</p>
</template>;
export default FKText;

View File

@ -0,0 +1,8 @@
export const VALIDATION_TYPES = {
submit: "submit",
change: "change",
focusout: "focusout",
input: "input",
};
export const NO_VALUE_OPTION = "__NONE__";

View File

@ -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<Object>} 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 });
});
}
}

View File

@ -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<void>} 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 = [];
}
}

View File

@ -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,
};
}
}

View File

@ -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");
}
}
}

View File

@ -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(`<img src='${image}'>`);
}

View File

@ -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"

View File

@ -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.");
});
});

View File

@ -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);
},
};
},
};
};
}

View File

@ -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);
},
};
}

View File

@ -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;

View File

@ -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(<template>
<Form @data={{hash foo=(array (hash bar=1) (hash bar=2))}} as |form|>
<form.Collection @name="foo" as |collection|>
<collection.Field @name="bar" @title="Bar" as |field|>
<field.Input />
</collection.Field>
</form.Collection>
</Form>
</template>);
assert.form().field("foo.0.bar").hasValue("1");
assert.form().field("foo.1.bar").hasValue("2");
});
test("remove", async function (assert) {
await render(<template>
<Form @data={{hash foo=(array (hash bar=1) (hash bar=2))}} as |form|>
<form.Collection @name="foo" as |collection index|>
<collection.Field @name="bar" @title="Bar" as |field|>
<field.Input />
<form.Button
class={{concat "remove-" index}}
@action={{fn collection.remove index}}
>Remove</form.Button>
</collection.Field>
</form.Collection>
</Form>
</template>);
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();
});
});

View File

@ -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(<template>
<Form @onSubmit={{mutateData}} @data={{data}} as |form|>
<form.Field
@name="foo"
@title="Foo"
@validation="length:0,5"
as |field|
>
<field.Input />
</form.Field>
</Form>
</template>);
assert.form().field("foo").hasCharCounter(0, 5);
await formKit().field("foo").fillIn("foo");
assert.form().field("foo").hasCharCounter(3, 5);
});
}
);

View File

@ -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(<template>
<Form @onSubmit={{mutateData}} @data={{data}} as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Checkbox />
</form.Field>
</Form>
</template>);
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 });
});
}
);

View File

@ -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(<template>
<Form @onSubmit={{mutateData}} @data={{data}} as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Code />
</form.Field>
</Form>
</template>);
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");
});
});

View File

@ -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(<template>
<Form @onSubmit={{mutateData}} @data={{data}} as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Composer />
</form.Field>
</Form>
</template>);
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" });
});
}
);

View File

@ -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(<template>
<Form @onSubmit={{mutateData}} @data={{data}} as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Icon />
</form.Field>
</Form>
</template>);
await formKit().field("foo").select("pencil-alt");
await formKit().submit();
assert.deepEqual(data.foo, "pencil-alt");
assert.form().field("foo").hasValue("pencil-alt");
});
});

View File

@ -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(<template>
<Form @mutable={{true}} @data={{data}} as |form|>
<form.Field @name="image_url" @title="Foo" as |field|>
<field.Image />
</form.Field>
</Form>
</template>);
await formKit().submit();
assert.form().field("image_url").hasValue(data.image_url);
});
}
);

View File

@ -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(<template>
<Form @onSubmit={{mutateData}} @data={{data}} as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Input />
</form.Field>
</Form>
</template>);
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(<template>
<Form @onSubmit={{mutateData}} @data={{data}} as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Input @type="number" />
</form.Field>
</Form>
</template>);
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);
});
}
);

View File

@ -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(<template>
<Form @onSubmit={{mutateData}} @data={{data}} as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Menu as |menu|>
<menu.Item @value="item-1">Item 1</menu.Item>
<menu.Item @value="item-2">Item 2</menu.Item>
<menu.Item @value="item-3">Item 3</menu.Item>
</field.Menu>
</form.Field>
</Form>
</template>);
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");
});
});

View File

@ -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(<template>
<Form @onSubmit={{mutateData}} @data={{data}} as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Password />
</form.Field>
</Form>
</template>);
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(<template>
<Form @data={{data}} as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Password />
</form.Field>
</Form>
</template>);
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");
});
}
);

View File

@ -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(<template>
<Form @onSubmit={{mutateData}} @data={{data}} as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Question />
</form.Field>
</Form>
</template>);
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(<template>
<Form as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Question @yesLabel="Correct" />
</form.Field>
</Form>
</template>);
assert.dom(".form-kit__control-radio-label.--yes").hasText("Correct");
});
test("@noLabel", async function (assert) {
await render(<template>
<Form as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Question @noLabel="Wrong" />
</form.Field>
</Form>
</template>);
assert.dom(".form-kit__control-radio-label.--no").hasText("Wrong");
});
}
);

View File

@ -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(<template>
<Form @onSubmit={{mutateData}} @data={{data}} as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.RadioGroup as |RadioGroup|>
<RadioGroup.Radio @value="one">One</RadioGroup.Radio>
<RadioGroup.Radio @value="two">Two</RadioGroup.Radio>
<RadioGroup.Radio @value="three">Three</RadioGroup.Radio>
</field.RadioGroup>
</form.Field>
</Form>
</template>);
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");
});
}
);

View File

@ -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(<template>
<Form @onSubmit={{mutateData}} @data={{data}} as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Select as |select|>
<select.Option @value="option-1">Option 1</select.Option>
<select.Option @value="option-2">Option 2</select.Option>
<select.Option @value="option-3">Option 3</select.Option>
</field.Select>
</form.Field>
</Form>
</template>);
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" });
});
}
);

View File

@ -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(<template>
<Form @onSubmit={{mutateData}} @data={{data}} as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Textarea />
</form.Field>
</Form>
</template>);
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" });
});
}
);

View File

@ -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(<template>
<Form @onSubmit={{mutateData}} @data={{data}} as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Toggle />
</form.Field>
</Form>
</template>);
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 });
});
}
);

View File

@ -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(<template>
<Form as |form|>
<form.Field @name="foo" @title="Foo" @size={{8}}>
Test
</form.Field>
</Form>
</template>);
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(<template>
<Form as |form|>
<form.Field @name="foo.bar" @title="Foo" @size={{8}}>
Test
</form.Field>
</Form>
</template>);
resetOnerror();
});
test("non existing title", async function (assert) {
setupOnerror((error) => {
assert.deepEqual(
error.message,
"@title is required on `<form.Field />`."
);
});
await render(<template>
<Form as |form|>
<form.Field @name="foo" @size={{8}}>
Test
</form.Field>
</Form>
</template>);
resetOnerror();
});
test("@validation", async function (assert) {
await render(<template>
<Form as |form|>
<form.Field @name="foo" @title="Foo" @validation="required" as |field|>
<field.Input />
</form.Field>
<form.Field @name="bar" @title="Bar" @validation="required" as |field|>
<field.Input />
</form.Field>
</Form>
</template>);
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(<template>
<Form @data={{hash foo="bar"}} as |form|>
<form.Field @name="foo" @title="Foo" @validate={{validate}} as |field|>
<field.Input />
</form.Field>
<form.Submit />
</Form>
</template>);
await formKit().submit();
assert
.form()
.field("foo")
.hasError("error", "the callback has the addError helper as param");
});
test("@showTitle", async function (assert) {
await render(<template>
<Form as |form|>
<form.Field
@name="foo"
@title="Foo"
@showTitle={{false}}
as |field|
><field.Input /></form.Field>
</Form>
</template>);
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(<template>
<Form as |form|>
<form.Field @name="foo" @title="Foo" @onSet={{onSet}} as |field|>
<field.Input />
</form.Field>
</Form>
</template>);
await fillIn("input", "bar");
});
});

View File

@ -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(<template>
<Form @data={{hash foo=1}} @onSubmit={{onSubmit}} />
</template>);
await formKit().submit();
});
test("addItemToCollection", async function (assert) {
await render(<template>
<Form @data={{hash foo=(array (hash bar=1) (hash bar=2))}} as |form|>
<form.Button
@action={{fn form.addItemToCollection "foo" (hash bar=3)}}
>Add</form.Button>
<form.Collection @name="foo" as |collection|>
<collection.Field @name="bar" @title="Bar" as |field|>
<field.Input />
</collection.Field>
</form.Collection>
</Form>
</template>);
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(<template>
<Form @data={{hash foo=1 bar=2}} @validate={{validate}} as |form|>
<form.Field @name="foo" @title="Foo" />
<form.Field @name="bar" @title="Bar" />
</Form>
</template>);
await formKit().submit();
assert.form().hasErrors({
foo: "incorrect type, required",
bar: "error",
});
});
test("@validateOn", async function (assert) {
const data = { foo: "test" };
await render(<template>
<Form @data={{data}} as |form|>
<form.Field @name="foo" @title="Foo" @validation="required" as |field|>
<field.Input />
</form.Field>
<form.Field @name="bar" @title="Bar" @validation="required" as |field|>
<field.Input />
</form.Field>
<form.Submit />
</Form>
</template>);
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(<template>
<Form
@data={{model}}
@onSubmit={{submit}}
@onRegisterApi={{registerApi}}
as |form data|
>
<div class="bar">{{data.bar}}</div>
</Form>
</template>);
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(<template>
<Form @data={{hash foo=1}} as |form data|>
<div class="foo">{{data.foo}}</div>
</Form>
</template>);
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(<template>
<Form @data={{hash bar=1}} @onReset={{onReset}} as |form|>
<form.Field @title="Foo" @name="foo" @validation="required" as |field|>
<field.Input />
</form.Field>
<form.Field @title="Bar" @name="bar" as |field|>
<field.Input />
</form.Field>
<form.Button class="set-bar" @action={{fn form.set "bar" 2}} />
</Form>
</template>);
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(<template>
<Form @data={{data}} as |form|>
<form.Field @name="foo" @title="Foo" as |field|>
<field.Input />
</form.Field>
<form.Button class="set-foo" @action={{fn form.set "foo" 2}} />
</Form>
</template>);
await click(".set-foo");
assert.deepEqual(data.foo, 1);
});
test("yielded set", async function (assert) {
await render(<template>
<Form @data={{hash foo=1}} as |form data|>
<div class="foo">{{data.foo}}</div>
<form.Button class="test" @action={{fn form.set "foo" 2}} />
</Form>
</template>);
await click(".test");
assert.dom(".foo").hasText("2");
});
});

View File

@ -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(<template>
<Form @onSubmit={{mutateData}} @data={{data}} as |form|>
<form.InputGroup as |inputGroup|>
<inputGroup.Field @title="Foo" @name="foo" as |field|>
<field.Input />
</inputGroup.Field>
<inputGroup.Field @title="Bar" @name="bar" as |field|>
<field.Input />
</inputGroup.Field>
</form.InputGroup>
</Form>
</template>);
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" });
});
}
);

View File

@ -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(<template>
<Form as |form|>
<form.Actions class="something">Test</form.Actions>
</Form>
</template>);
assert
.dom(".form-kit__section.form-kit__actions.something")
.hasText("Test");
});
}
);

View File

@ -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(<template>
<Form as |form|>
<form.Alert>Test</form.Alert>
</Form>
</template>);
assert.dom(".form-kit__alert-message").hasText("Test");
});
test("@icon", async function (assert) {
await render(<template>
<Form as |form|>
<form.Alert @icon="pencil-alt">Test</form.Alert>
</Form>
</template>);
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(<template>
<Form as |form|>
<form.Alert @type={{type}}>Test</form.Alert>
</Form>
</template>);
assert.dom(`.form-kit__alert.alert.alert-${type}`).exists();
}
});
});

View File

@ -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(<template>
<Form as |form|>
<form.Button class="something" @action={{fn somethingAction 1}} />
</Form>
</template>);
await click(".something");
});
});

View File

@ -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(<template>
<Form as |form|>
<form.Container class="something">Test</form.Container>
</Form>
</template>);
assert
.dom(".form-kit__container.something .form-kit__container-content")
.hasText("Test");
});
test("@title", async function (assert) {
await render(<template>
<Form as |form|>
<form.Container @title="Title">Test</form.Container>
</Form>
</template>);
assert
.dom(".form-kit__container .form-kit__container-title")
.hasText("Title");
});
test("@subtitle", async function (assert) {
await render(<template>
<Form as |form|>
<form.Container @subtitle="Subtitle">Test</form.Container>
</Form>
</template>);
assert
.dom(".form-kit__container .form-kit__container-subtitle")
.hasText("Subtitle");
});
}
);

View File

@ -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(<template>
<Form as |form|>
<form.Row as |row|>
<row.Col>Test</row.Col>
</form.Row>
</Form>
</template>);
assert.dom(".form-kit__row .form-kit__col").hasText("Test");
});
test("@size", async function (assert) {
await render(<template>
<Form as |form|>
<form.Row as |row|>
<row.Col @size={{6}}>Test</row.Col>
</form.Row>
</Form>
</template>);
assert.dom(".form-kit__row .form-kit__col.--col-6").hasText("Test");
});
});

View File

@ -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(<template>
<Form as |form|>
<form.Section class="something">Test</form.Section>
</Form>
</template>);
assert.dom(".form-kit__section.something").hasText("Test");
});
test("@title", async function (assert) {
await render(<template>
<Form as |form|>
<form.Section @title="Title">Test</form.Section>
</Form>
</template>);
assert
.dom(".form-kit__section .form-kit__section-title")
.hasText("Title");
});
test("@subtitle", async function (assert) {
await render(<template>
<Form as |form|>
<form.Section @subtitle="Subtitle">Test</form.Section>
</Form>
</template>);
assert
.dom(".form-kit__section .form-kit__section-subtitle")
.hasText("Subtitle");
});
}
);

View File

@ -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(<template>
<Form @onSubmit={{submit}} as |form|>
<form.Submit />
</Form>
</template>);
await click("button");
assert.dom(".form-kit__button.btn-primary").hasText(I18n.t("submit"));
assert.deepEqual(value, 1);
});
});

View File

@ -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 });
});
});

View File

@ -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"
);
});
});

View File

@ -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,

View File

@ -21,3 +21,4 @@
@import "common/login/_index";
@import "common/table-builder/_index";
@import "common/post-action-feedback";
@import "common/form-kit/_index";

View File

@ -341,7 +341,6 @@ $mobile-breakpoint: 700px;
}
.admin-content {
margin-bottom: 50px;
.admin-contents {
padding: 0 0 8px 0;
@include clearfix();

View File

@ -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);

View File

@ -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 {

View File

@ -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 {

View File

@ -28,6 +28,7 @@
position: relative;
display: inline-block;
cursor: pointer;
margin: 0;
}
&__checkbox {

View File

@ -0,0 +1,7 @@
.form-kit__alert {
//reset
margin: 0;
width: 100%;
border-radius: var(--d-border-radius);
box-sizing: border-box;
}

View File

@ -0,0 +1,8 @@
.form-kit__char-counter {
margin-left: auto;
padding-top: 0.15em;
&.--exceeded {
color: var(--danger);
}
}

View File

@ -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%;
}

View File

@ -0,0 +1,5 @@
.form-kit__conditional-display {
.form-kit__inline-radio {
padding-bottom: 0.25rem;
}
}

View File

@ -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%;
}
}

View File

@ -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));
}
}

View File

@ -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;
}
}
}

Some files were not shown because too many files have changed in this diff Show More