import Component from "@glimmer/component"; import EmberObject, { action } from "@ember/object"; import { inject as service } from "@ember/service"; import { dasherize } from "@ember/string"; import { isEmpty } from "@ember/utils"; import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner"; import Form from "discourse/components/form"; import Category from "discourse/models/category"; import I18n from "I18n"; import BooleanThree from "./param-input/boolean-three"; import CategoryIdInput from "./param-input/category-id-input"; import GroupListInput from "./param-input/group-list-input"; import UserIdInput from "./param-input/user-id-input"; import UserListInput from "./param-input/user-list-input"; export class ParamValidationError extends Error {} const layoutMap = { int: "int", bigint: "string", boolean: "boolean", string: "string", time: "generic", date: "generic", datetime: "generic", double: "string", user_id: "user_id", post_id: "string", topic_id: "generic", category_id: "category_id", group_id: "generic", badge_id: "generic", int_list: "generic", string_list: "generic", user_list: "user_list", group_list: "group_list", }; const ERRORS = { REQUIRED: I18n.t("form_kit.errors.required"), NOT_AN_INTEGER: I18n.t("form_kit.errors.not_an_integer"), NOT_A_NUMBER: I18n.t("form_kit.errors.not_a_number"), OVERFLOW_HIGH: I18n.t("form_kit.errors.too_high", { count: 2147484647 }), OVERFLOW_LOW: I18n.t("form_kit.errors.too_low", { count: -2147484648 }), INVALID: I18n.t("explorer.form.errors.invalid"), NO_SUCH_CATEGORY: I18n.t("explorer.form.errors.no_such_category"), NO_SUCH_GROUP: I18n.t("explorer.form.errors.no_such_group"), }; function digitalizeCategoryId(value) { value = String(value || ""); const isPositiveInt = /^\d+$/.test(value); if (!isPositiveInt && value.trim()) { return Category.asyncFindBySlugPath(dasherize(value)) .then((res) => res.id) .catch((err) => { if (err.jqXHR?.status === 404) { throw new ParamValidationError( `${ERRORS.NO_SUCH_CATEGORY}: ${value}` ); } else { throw new Error(err.errorThrow || err.message); } }); } return value; } function normalizeValue(info, value) { switch (info.type) { case "category_id": return digitalizeCategoryId(value); case "boolean": if (value == null) { return info.nullable ? "#null" : false; } return value; case "group_list": case "user_list": if (Array.isArray(value)) { return value || null; } return value?.split(",") || null; case "user_id": if (Array.isArray(value)) { return value[0]; } return value; default: return value; } } function serializeValue(type, value) { switch (type) { case "string": case "int": return value != null ? String(value) : ""; case "boolean": return String(value); case "group_list": case "user_list": return value?.join(","); default: return value?.toString(); } } function validationOf(info) { switch (layoutMap[info.type]) { case "boolean": return info.nullable ? "required" : ""; case "string": case "string_list": case "generic": return info.nullable ? "" : "required:trim"; default: return info.nullable ? "" : "required"; } } function componentOf(info) { let type = layoutMap[info.type] || "generic"; if (info.nullable && type === "boolean") { type = "boolean_three"; } switch (type) { case "int": return ; case "boolean": return ; case "boolean_three": return BooleanThree; case "category_id": // TODO return CategoryIdInput; case "user_id": return UserIdInput; case "user_list": return UserListInput; case "group_list": return GroupListInput; case "bigint": case "string": default: return ; } } export default class ParamInputForm extends Component { @service site; data = {}; paramInfo = []; infoOf = {}; form = null; promiseNormalizations = []; constructor() { super(...arguments); this.initializeParams(); this.args.onRegisterApi?.({ submit: this.submit, allNormalized: Promise.allSettled(this.promiseNormalizations), }); } initializeParams() { this.args.paramInfo.forEach((info) => { const identifier = info.identifier; const pinfo = this.createParamInfo(info); this.paramInfo.push(pinfo); this.infoOf[identifier] = info; const normalized = this.getNormalizedValue(info); if (normalized instanceof Promise) { this.handlePromiseNormalization(normalized, pinfo); } else { this.data[identifier] = normalized; } }); } createParamInfo(info) { return EmberObject.create({ ...info, validation: validationOf(info), validate: this.validatorOf(info), component: componentOf(info), }); } getNormalizedValue(info) { const initialValues = this.args.initialValues; const identifier = info.identifier; return normalizeValue( info, initialValues && identifier in initialValues ? initialValues[identifier] : info.default ); } handlePromiseNormalization(promise, pinfo) { this.promiseNormalizations.push(promise); pinfo.set("loading", true); this.data[pinfo.identifier] = null; promise .then((res) => this.form.set(pinfo.identifier, res)) .catch((err) => this.form.addError(pinfo.identifier, { title: pinfo.identifier, message: err.message, }) ) .finally(() => pinfo.set("loading", false)); } getErrorFn(info) { const isPositiveInt = (value) => /^\d+$/.test(value); const VALIDATORS = { int: (value) => { if (value >= 2147483648) { return ERRORS.OVERFLOW_HIGH; } if (value <= -2147483649) { return ERRORS.OVERFLOW_LOW; } return null; }, bigint: (value) => { if (isNaN(parseInt(value, 10))) { return ERRORS.NOT_A_NUMBER; } return /^-?\d+$/.test(value) ? null : ERRORS.NOT_AN_INTEGER; }, boolean: (value) => { return /^Y|N|#null|true|false/.test(String(value)) ? null : ERRORS.INVALID; }, double: (value) => { if (isNaN(parseFloat(value))) { if (/^(-?)Inf(inity)?$/i.test(value) || /^(-?)NaN$/i.test(value)) { return null; } return ERRORS.NOT_A_NUMBER; } return null; }, int_list: (value) => { return value.split(",").every((i) => /^(-?\d+|null)$/.test(i.trim())) ? null : ERRORS.INVALID; }, post_id: (value) => { return isPositiveInt(value) || /\d+\/\d+(\?u=.*)?$/.test(value) || /\/t\/[^/]+\/(\d+)(\?u=.*)?/.test(value) ? null : ERRORS.INVALID; }, topic_id: (value) => { return isPositiveInt(value) || /\/t\/[^/]+\/(\d+)/.test(value) ? null : ERRORS.INVALID; }, category_id: (value) => { return this.site.categoriesById.get(Number(value)) ? null : ERRORS.NO_SUCH_CATEGORY; }, group_id: (value) => { const groups = this.site.get("groups"); if (isPositiveInt(value)) { const intVal = parseInt(value, 10); return groups.find((g) => g.id === intVal) ? null : ERRORS.NO_SUCH_GROUP; } else { return groups.find((g) => g.name === value) ? null : ERRORS.NO_SUCH_GROUP; } }, }; return VALIDATORS[info.type] ?? (() => null); } validatorOf(info) { const getError = this.getErrorFn(info); return (name, value, { addError }) => { // skip require validation for we have used them in @validation if (isEmpty(value) || value == null) { return; } const message = getError(value); if (message != null) { addError(name, { title: info.identifier, message }); } }; } @action async submit() { if (this.form == null) { throw "No form"; } this.serializedData = null; await this.form.submit(); if (this.serializedData == null) { throw new ParamValidationError("validation_failed"); } else { return this.serializedData; } } @action onRegisterApi(form) { this.form = form; } @action onSubmit(data) { const serializedData = {}; for (const [id, val] of Object.entries(data)) { serializedData[id] = serializeValue(this.infoOf[id].type, val) ?? undefined; } this.serializedData = serializedData; } }