Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UX: Rewrite param-input using FormKit #307

Merged
merged 8 commits into from
Aug 20, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class QuerySerializer < ActiveModel::Serializer
:user_id

def param_info
object&.params&.map(&:to_hash)
object&.params&.uniq { |p| p.identifier }&.map(&:to_hash)
end

def username
Expand Down
328 changes: 328 additions & 0 deletions assets/javascripts/discourse/components/param-input-form.gjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { dasherize } from "@ember/string";
import { isEmpty } from "@ember/utils";
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";

const layoutMap = {
Lhcfl marked this conversation as resolved.
Show resolved Hide resolved
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) {
if (/\//.test(value)) {
const match = /(.*)\/(.*)/.exec(value);
if (!match) {
value = null;
} else {
value = Category.findBySlug(
dasherize(match[2]),
dasherize(match[1])
)?.id;
}
} else {
value = Category.findBySlug(dasherize(value))?.id;
Lhcfl marked this conversation as resolved.
Show resolved Hide resolved
}
}
return value?.toString();
}

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 <template>
<@field.Input @type="number" name={{@info.identifier}} />
</template>;
case "boolean":
return <template><@field.Checkbox name={{@info.identifier}} /></template>;
nattsw marked this conversation as resolved.
Show resolved Hide resolved
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 <template><@field.Input name={{@info.identifier}} /></template>;
}
}

export default class ParamInputForm extends Component {
@service site;
data = {};
paramInfo = [];
infoOf = {};
form = null;

constructor() {
super(...arguments);

const initialValues = this.args.initialValues;
for (const info of this.args.paramInfo) {
const identifier = info.identifier;

// access parsed params if present to update values to previously ran values
let initialValue;
if (initialValues && identifier in initialValues) {
initialValue = initialValues[identifier];
} else {
// if no parsed params then get and set default values
initialValue = info.default;
}
this.data[identifier] = normalizeValue(info, initialValue);
this.paramInfo.push({
...info,
validation: validationOf(info),
validate: this.validatorOf(info),
component: componentOf(info),
});
this.infoOf[identifier] = info;
}

this.args.onRegisterApi?.({
submit: this.submit,
});
}

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";
}
await this.form.submit();
if (this.serializedData == null) {
throw "validation_failed";
Lhcfl marked this conversation as resolved.
Show resolved Hide resolved
} else {
return this.serializedData;
}
}

@action
onRegisterApi(form) {
this.form = form;
}

@action
onSubmit(data) {
this.serializedData = null;
const serializedData = {};
for (const [id, val] of Object.entries(data)) {
serializedData[id] =
serializeValue(this.infoOf[id].type, val) ?? undefined;
}
this.serializedData = serializedData;
}

<template>
<div class="query-params">
<Form
@data={{this.data}}
@onRegisterApi={{this.onRegisterApi}}
@onSubmit={{this.onSubmit}}
class="params-form"
as |form|
>
{{#each this.paramInfo as |info|}}
<div class="param">
<form.Field
@name={{info.identifier}}
@title={{info.identifier}}
@validation={{info.validation}}
@validate={{info.validate}}
as |field|
>
<info.component @field={{field}} @info={{info}} />
</form.Field>
</div>
{{/each}}
</Form>
Comment on lines +307 to +327
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im impressed, you have greatly managed to integrate form kit into something which is not "standard" at all 👏

</div>
</template>
}
Loading