import Component from "@glimmer/component"; import EmberObject, { action } from "@ember/object"; import { 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 "discourse-i18n"; import BooleanThree from "./param-input/boolean-three"; import CategoryIdInput from "./param-input/category-id-input"; import GroupInput from "./param-input/group-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: "time", date: "date", datetime: "datetime", double: "string", user_id: "user_id", post_id: "string", topic_id: "generic", category_id: "category_id", group_id: "group_list", badge_id: "generic", int_list: "generic", string_list: "generic", user_list: "user_list", group_list: "group_list", }; export const ERRORS = { REQUIRED: i18n("form_kit.errors.required"), NOT_AN_INTEGER: i18n("form_kit.errors.not_an_integer"), NOT_A_NUMBER: i18n("form_kit.errors.not_a_number"), OVERFLOW_HIGH: i18n("form_kit.errors.too_high", { count: 2147484647 }), OVERFLOW_LOW: i18n("form_kit.errors.too_low", { count: -2147484648 }), INVALID: i18n("explorer.form.errors.invalid"), NO_SUCH_CATEGORY: i18n("explorer.form.errors.no_such_category"), NO_SUCH_GROUP: i18n("explorer.form.errors.no_such_group"), INVALID_DATE: (date) => i18n("explorer.form.errors.invalid_date", { date }), INVALID_TIME: (time) => i18n("explorer.form.errors.invalid_time", { time }), }; 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 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(","); case "group_id": return value[0]; case "datetime": return value?.replaceAll("T", " "); 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"; } } const components = { int: , boolean: , boolean_three: BooleanThree, category_id: CategoryIdInput, // TODO user_id: UserIdInput, user_list: UserListInput, group_list: GroupInput, date: , time: , datetime: , default: , }; function componentOf(info) { let type = layoutMap[info.type] || "generic"; if (info.nullable && type === "boolean") { type = "boolean_three"; } return components[type] || components.default; } export default class ParamInputForm extends Component { @service site; data = {}; paramInfo = []; infoOf = {}; form = null; promiseNormalizations = []; formLoaded = new Promise((res) => { this.__form_load_callback = res; }); 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), }); } @action async addError(identifier, message) { await this.formLoaded; this.form.addError(identifier, { title: identifier, message, }); } @action normalizeValue(info, value) { switch (info.type) { case "category_id": return digitalizeCategoryId(value); case "boolean": if (value == null || value === "#null") { return info.nullable ? "#null" : false; } return value === "true"; case "group_id": case "group_list": const normalized = this.normalizeGroups(value); if (normalized.errorMsg) { this.addError(info.identifier, normalized.errorMsg); } return info.type === "group_id" ? normalized.value.slice(0, 1) : normalized.value; 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; case "date": try { if (!value) { return null; } return moment(value).format("YYYY-MM-DD"); } catch { this.addError(info.identifier, ERRORS.INVALID_DATE(String(value))); return null; } case "time": try { if (!value) { return null; } return moment(new Date(`1970/01/01 ${value}`).toISOString()).format( "HH:mm" ); } catch { this.addError(info.identifier, ERRORS.INVALID_TIME(String(value))); return null; } case "datetime": try { if (!value) { return null; } return moment(new Date(value).toISOString()).format( "YYYY-MM-DD HH:mm" ); } catch { this.addError(info.identifier, ERRORS.INVALID_TIME(String(value))); return null; } default: return value; } } getNormalizedValue(info) { const initialValues = this.args.initialValues; const identifier = info.identifier; return this.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.addError(pinfo.identifier, err.message)) .finally(() => pinfo.set("loading", false)); } @action normalizeGroups(values) { values ||= []; if (typeof values === "string") { values = values.split(","); } const GroupNames = new Set(this.site.get("groups").map((g) => g.name)); const GroupNameOf = Object.fromEntries( this.site.get("groups").map((g) => [g.id, g.name]) ); const valid_groups = []; const invalid_groups = []; for (const val of values) { if (GroupNames.has(val)) { valid_groups.push(val); } else if (GroupNameOf[Number(val)]) { valid_groups.push(GroupNameOf[Number(val)]); } else { invalid_groups.push(String(val)); } } return { value: valid_groups, errorMsg: invalid_groups.length !== 0 ? `${ERRORS.NO_SUCH_GROUP}: ${invalid_groups.join(", ")}` : null, }; } 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_list: (value) => { return this.normalizeGroups(value).errorMsg; }, group_id: (value) => { return this.normalizeGroups(value).errorMsg; }, }; 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_load_callback(); 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; } }