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:
parent
bae492efee
commit
2ca06ba236
|
@ -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>";
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = [];
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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}}
|
|
@ -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({
|
|
@ -67,6 +67,7 @@
|
|||
@focusIn={{action "focusIn"}}
|
||||
@focusOut={{action "focusOut"}}
|
||||
class="d-editor-input"
|
||||
@id={{this.textAreaId}}
|
||||
/>
|
||||
<PopupInputTip @validation={{this.validation}} />
|
||||
<PluginOutlet
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import Form from "discourse/form-kit/components/fk/form";
|
||||
|
||||
export default Form;
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
const FKControlMenuContainer = <template>
|
||||
<li class="form-kit__control-menu-container">
|
||||
{{yield}}
|
||||
</li>
|
||||
</template>;
|
||||
|
||||
export default FKControlMenuContainer;
|
|
@ -0,0 +1,5 @@
|
|||
const FKControlMenuDivider = <template>
|
||||
<@divider class="form-kit__control-menu-divider" />
|
||||
</template>;
|
||||
|
||||
export default FKControlMenuDivider;
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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;
|
|
@ -0,0 +1,7 @@
|
|||
const FKLabel = <template>
|
||||
<label for={{@fieldId}} ...attributes>
|
||||
{{yield}}
|
||||
</label>
|
||||
</template>;
|
||||
|
||||
export default FKLabel;
|
|
@ -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>
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,7 @@
|
|||
const FKText = <template>
|
||||
<p class="form-kit-text" ...attributes>
|
||||
{{yield}}
|
||||
</p>
|
||||
</template>;
|
||||
|
||||
export default FKText;
|
|
@ -0,0 +1,8 @@
|
|||
export const VALIDATION_TYPES = {
|
||||
submit: "submit",
|
||||
change: "change",
|
||||
focusout: "focusout",
|
||||
input: "input",
|
||||
};
|
||||
|
||||
export const NO_VALUE_OPTION = "__NONE__";
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}'>`);
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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.");
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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" });
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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" });
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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" });
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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" });
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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 });
|
||||
});
|
||||
});
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -21,3 +21,4 @@
|
|||
@import "common/login/_index";
|
||||
@import "common/table-builder/_index";
|
||||
@import "common/post-action-feedback";
|
||||
@import "common/form-kit/_index";
|
||||
|
|
|
@ -341,7 +341,6 @@ $mobile-breakpoint: 700px;
|
|||
}
|
||||
|
||||
.admin-content {
|
||||
margin-bottom: 50px;
|
||||
.admin-contents {
|
||||
padding: 0 0 8px 0;
|
||||
@include clearfix();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__checkbox {
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
.form-kit__alert {
|
||||
//reset
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
border-radius: var(--d-border-radius);
|
||||
box-sizing: border-box;
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
.form-kit__char-counter {
|
||||
margin-left: auto;
|
||||
padding-top: 0.15em;
|
||||
|
||||
&.--exceeded {
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
|
@ -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%;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
.form-kit__conditional-display {
|
||||
.form-kit__inline-radio {
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
}
|
|
@ -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%;
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Reference in New Issue