Skip to content

Commit

Permalink
MHDialog tidyup, validator implementation, documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
esheyw committed Jan 19, 2024
1 parent 8d955ac commit 85f4048
Show file tree
Hide file tree
Showing 13 changed files with 252 additions and 65 deletions.
47 changes: 45 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ It is a collection of macros and helper functions I've written for PF2e. It will

[Patch Notes](https://github.com/esheyw/pf2e-macro-helper-library/blob/main/CHANGELOG.md)

## Existing Macros:
## Macros
Macros are accessed via `game.pf2emhl.macros.`
#### Fascinating Performance (`async fascinatingPerformance()`)
Requires one token selected, and at least one target. Has handling for target limits depending on Performance rank, and will ignore any targets with an effect that contains both "Immun" and "Fascinating Performance" in its name, case-**in**sensitive. TODO: Build immunity effect, apply as appropriate (existing behaviour is a holdover from standalone macro)
Expand All @@ -15,7 +15,7 @@ For recovering weapons hidden in flags by the old Lashing Currents macro origina
#### Drop Held Torch (`async dropHeldTorch()`) *Requires Item Piles*
Requires one token only selected, and a currently held torch. Creates an Item Pile containing the torch, removing it from the actor. If the torch was lit, apply that light to the resulting pile token. Significant generalization and improvements planned.

## Existing Helper Functions:
## Helper Functions
Helpers are accessed via `game.pf2emhl.`

---
Expand Down Expand Up @@ -97,3 +97,46 @@ Example image (produced via pickItemFromActor above):

The purple text is the `indentifier`, which can be supplied to disambiguated things with duplicate names. Currently produces a single button per provided `thing`, regardless of thing count.
**TODO**: implement select menu fallback for > configurable limit of items, improve styling generally.

---
## Classes
### `MHLDialog`
MHLDialog is designed to be a drop-in replacement for the foundry Dialog class, with a few improvements:
#### Defaults to `jQuery:false` in options
This is mostly personal preference, but it means that any callbacks you use with this class should assume they will be passed an HTMLElement instead of a jQuery object, unless you specify `jQuery:true` in your dialog options object.
#### Doesn't clobber the classes array
In base Dialog, the way Application handles merging the options object, if you specify `classes:["my-class"]` as part of your dialog options, it will overwrite the array entirely, removing the `"dialog"` class. MHLDialog includes a workaround for this, and adds its own class (`"mhldialog"`) to the list in addition to whatever you give it.
#### Handlebars as `content`
Supports passing either a path to a handlebars file (must have extension `.html` or `.hbs`), or an inline handlebars template string, as `content` in dialog data. The temlpate is compiled and then passed the contents of the `contentData` property, in addition to the `buttons` and `content` variables that the base class provides, as well as the `idPrefix` variable, which is set to `mhldialog-${this.appId}-`. This last allows [valid-by-html-rules](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/id) `id` properties on your form inputs, and associated labels, eg:
```hbs
<form>
<div class="form-group">
<label for="{{idPrefix}}text">Input Text: </label>
<div class="form-fields">
<input type="text" name="text" id="{{idPrefix}}text"/>
</div>
</div>
</form>
```
#### Restricting form submission (required fields)
Supports passing a `validator` property along with the dialog data. This can either be:
- A function (that takes the root element (respecting the `jQuery` option, which MHLDialog defaults to `false`) of the dialog and returns a boolean)
- An array of strings equating to the `name`s of form elements that are not allowed to be empty
- A single string `name` (gets put into an array and treated as above)
If passed either non-function option, the default validator will produce a banner if validation is failed: ![](https://i.imgur.com/EfbNTWE.png)

#### Static methods `MHLDialog.getFormData(html)` and `MHLDialog.getFormData(html)`
`getFormsData` takes in html (or jQuery), and, for each form in the data, runs that form through `new FormDataExtended`, and assigns the output to an object, with the key of the form's name, eg:
```js
{
"formname1": {
"fieldname1":"value",
"fieldname2":"value"
},
"formname2":{
//etc
}
}
```
If there is more than one form in `html`, and any forms lack a `name` attribute, will error. If there's only one form, if that form lacks a `name` attribute it will be default to just 'form'.
`getFormData` just calls `getFormsData` and returns the first form's data; the only difference between it and simple `(html) => new FormDataExtended(html).object` is `getFormsData`'s handling for multiple forms. Either function is suitable as a callback if you'd like to simple dump the form output and handle that separately (my preference over having all the logic in the callback).
5 changes: 1 addition & 4 deletions TODO
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,4 @@
- implement current column
- foundry package release api
- npm package setup, run link dev
- - or just a standalone script
- dupe check contentData in MHLDialog against reserved keys
- add submit validation callback to MHLDialog
- document MHLDialog
- or just a standalone script
22 changes: 16 additions & 6 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@
"MalformedThing": "Provided thing lacked valid label or value."
}
},
"Dialog" : {
"Dialog": {
"Warning": {
"RequiredFields":"This dialog requires one or more fields to be non-empty: {fields}"
},
"Error": {
"TemplateFailure": "The template filepath or literal passed to MHLDialog failed to compile properly.",
"FormRequiresName": "One or more of the forms provided lacks a `name` property."
"FormRequiresName": "One or more of the forms provided lacks a `name` property.",
"ReservedKeys":"The contentData object must not contain any of the following reserved keys: {keys}",
"BadValidator":"The supplied validator must either be a function or an array of value names required to be non-empty."
}
},
"Macro": {
Expand All @@ -39,6 +44,9 @@
}
},
"LashingCurrents": {
"Info": {
"Removing": "Removing Lashing Currents rules from \"{name}\"."
},
"Error": {
"NoneSelected": "No weapon selected.",
"NoExistingFound": "No old-style Lashing Currents weapon found on the actor of selected token \"{name}\"."
Expand Down Expand Up @@ -76,19 +84,21 @@
"NotAUser": "Provided user was not a foundry User."
}
},
"Error": {
"Error": {
"Generic": "You broke something.",
"BannerType":"Banner type must be one of \"info\", \"warn\", or \"error\".",
"BadErrorString": "Provided non-string to error handler localizer.",
"BannerType": "Banner type must be one of \"info\", \"warn\", or \"error\".",
"LogType": "Log type must be one of \"debug\", \"info\", \"warn\", or \"error\".",
"InvalidType": "Invalid type \"{type}\" provided.",
"Type": {
"Array": "\"{var}\" must be an Array{typestr}.",
"User": "\"{var}\" must be a User or the ID of one.",
"Folder": "\"{var}\" must be a Folder document or the ID of one.",
"Function": "\"{var}\" must be a Function.",
"Number": "\"{var}\" must be a Number.",
"String": "\"{var}\" must be a String."
}
},
"Warning":{
"Warning": {
"LevelOutOfBounds": "Provided level ({level}) out of bounds! Defaulting to level 25."
},
"Settings": {
Expand Down
109 changes: 90 additions & 19 deletions scripts/classes/MHLDialog.mjs
Original file line number Diff line number Diff line change
@@ -1,53 +1,108 @@
import { fu } from "../constants.mjs";
import { MHLError } from "../helpers/errorHelpers.mjs";
import { COLOURS, fu } from "../constants.mjs";
import { MHLError, isEmpty, localizedBanner, mhlog } from "../helpers/errorHelpers.mjs";
import { localize } from "../helpers/stringHelpers.mjs";
const PREFIX = "MHL.Dialog";
export class MHLDialog extends Dialog {
constructor(data, options = {}) {
if ("submitValidator" in data) {
const { submitValidator } = data;
if (typeof submitValidator !== "function")
//validate the validator. TODO: add facility for list of non-empty inputs instead of function
if ("validator" in data) {
let validator = data.validator;
switch (typeof validator) {
case "function":
break;
case "string":
validator = [validator];
case "object":
if (Array.isArray(validator) && validator.every((f) => typeof f === "string")) {
const fields = fu.deepClone(validator);
data.validator = (html) => {
const formValues = MHLDialog.getFormData(html);
const emptyFields = fields.filter((f) => isEmpty(formValues[f]));
if (emptyFields.length) {
const fieldsError = fields
.map((f) =>
emptyFields.includes(f)
? `<span style="text-decoration: underline wavy ${COLOURS["error"]}">${f}</span>`
: f
)
.join(", ");
localizedBanner(
`${PREFIX}.Warning.RequiredFields`,
{ fields: fieldsError },
{ type: "warn", log: { formValues }, console: false }
);
return false;
}
return true;
};
break;
}
default:
throw MHLError(`${PREFIX}.Error.BadValidator`, null, { func: "MHLDialog: ", log: { validator } });
}
}
//make sure contentData doesnt have reserved keys (just buttons and content afaict)
if ("contentData" in data) {
const contentData = data.contentData;
const disallowedKeys = ["buttons", "content"];
if (!Object.keys(contentData).every((k) => !disallowedKeys.includes(k))) {
throw MHLError(
`MHL.Error.Type.Function`,
{ var: "submitValidator" },
{ func: "MHLDialog: ", log: { submitValidator } }
`${PREFIX}.Error.ReservedKeys`,
{ keys: disallowedKeys.join(", ") },
{ func: "MHLDialog: ", log: { contentData } }
);
}
}

// gotta work around Application nuking the classes array with mergeObject
let tempClasses;
if ("classes" in options && Array.isArray(options.classes)) {
tempClasses = fu.deepClone(options.classes);
delete options.classes;
}
super(data, options);
if (tempClasses) this.options.classes = [...new Set(this.options.classes.concat(tempClasses))];
}

#_validate() {
if (!("validator" in this.data)) return true;
return this.data.validator(this.options.jQuery ? this.element : this.element[0]);
}

getData() {
return fu.mergeObject(super.getData(), {
idPrefix: `mhdialog-${this.appId}-`,
idPrefix: `mhldialog-${this.appId}-`,
...(this.data.contentData ?? {}),
});
}

static get defaultOptions() {
return fu.mergeObject(super.defaultOptions, {
jQuery: false,
classes: ["mhldialog"],
classes: ["mhldialog", ...super.defaultOptions.classes],
});
}

submit(button, event) {
if (this.data?.submitValidator && !this.data.submitValidator(this.element[0])) return false;
if (!this.#_validate()) return false;
super.submit(button, event);
}

async _renderInner(data) {
const originalContent = fu.deepClone(data.content);
if (/\.(hbs|html)$/.test(data.content)) {
data.content = await renderTemplate(originalContent, data);
} else {
data.content = Handlebars.compile(originalContent)(data);
if (data?.content) {
const originalContent = fu.deepClone(data.content);
if (/\.(hbs|html)$/.test(data.content)) {
data.content = await renderTemplate(originalContent, data);
} else {
data.content = Handlebars.compile(originalContent)(data);
}
data.content ||= localize(`${PREFIX}.Error.TemplateFailure`);
}
data.content ||= localize(`${PREFIX}.Error.TemplateFailure`);
return super._renderInner(data);
}

static getFormData(html) {
return Object.values(this.getFormsData(html))[0];
return Object.values(MHLDialog.getFormsData(html))[0];
}

static getFormsData(html) {
Expand All @@ -58,8 +113,24 @@ export class MHLDialog extends Dialog {
if (forms.length > 1 && !form?.name) {
throw MHLError(`${PREFIX}.Error.FormRequiresName`, null, { func: "defaultFormCallback: ", log: { forms } });
}
out[form?.name ?? "form"] = new FormDataExtended(curr).object; //if there's only one and it doesnt have a name, give it a default
//if there's only one and it doesnt have a name, give it a default
out[form?.name ?? "form"] = new FormDataExtended(form).object;
}
return out;
}

static getLabelMap(html) {
html = html instanceof jQuery ? html[0] : html;
const named = html.querySelectorAll("[name][id]");
if (!named.length) return {};
const namedIDs = Array.from(named).map((e) => e.getAttribute("id"));
const labels = html.querySelectorAll("[for]");
if (!labels.length) return {};
return Array.from(labels).reduce((acc, curr) => {
const forAttr = curr.getAttribute("for");
if (!namedIDs.includes(forAttr)) return acc;
acc[forAttr] = curr.innerText;
return acc;
}, {});
}
}
6 changes: 5 additions & 1 deletion scripts/constants.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
export const MODULE = 'pf2e-macro-helper-library';
export const NOTIFY = () => game.settings.get(MODULE,'notify-on-error');
export const PHYSICAL_ITEM_TYPES = ["armor", "backpack", "book", "consumable", "equipment", "shield", "treasure", "weapon"];
export const fu = foundry.utils;
export const CONSOLE_TYPES = ["debug","info","warn","error"];
export const BANNER_TYPES = CONSOLE_TYPES.slice(1)
export const COLOURS = {
error: 'var(--color-level-error, red)'
}
73 changes: 59 additions & 14 deletions scripts/helpers/errorHelpers.mjs
Original file line number Diff line number Diff line change
@@ -1,30 +1,75 @@
import { NOTIFY } from "../constants.mjs";
import { BANNER_TYPES, CONSOLE_TYPES } from "../constants.mjs";
import { NOTIFY } from "../settings.mjs";
import { localize } from "./stringHelpers.mjs";
export const PREFIX = "MHL";

export function localizedError(str, data = {}, { notify = null, prefix = "", log = {} } = {}) {
notify ??= NOTIFY;
notify ??= NOTIFY();
let errorstr = "" + prefix;
if (typeof log === "object" && Object.keys(log).length) console.error(log);
errorstr += typeof str === "string" ? localize(str, data) : localize(`${PREFIX}.Error.Type.String`);
if (typeof log === "object" && Object.keys(log).length) mhlog(log, "error");
if (typeof str !== "string") {
throw MHLError(`MHL.Error.Type.String`, { var: "str" }, { func: "localizedError", log: { str } });
}
errorstr += localize(str, data);
if (notify) ui.notifications.error(errorstr, { console: false });
return Error(errorstr);
}
export function localizedBanner(str, data = {}, { notify = null, prefix = "", log = {}, type="info" } = {}) {
const func = 'localizedBanner';
notify ??= NOTIFY;

export function localizedBanner(str, data = {}, { notify = null, prefix = "", log = {}, type = "info", console=true} = {}) {
const func = "localizedBanner";
notify ??= NOTIFY();
if (!notify) return false;
if (!['info','error','warn'].includes(type)) throw MHLError(`${PREFIX}.Error.BannerType`, null, {func, log:{type}})
if (!BANNER_TYPES.includes(type)) throw MHLError(`MHL.Error.BannerType`, null, { func, log: { type } });
let bannerstr = "" + prefix;
if (typeof log === "object" && Object.keys(log).length) console[type](log);
if (typeof str !== "string") throw MHLError(`${PREFIX}.Error.Type.String`, {var:'str'}, {func,log:{str}});
if (typeof log === "object" && Object.keys(log).length) mhlog(log,type);
if (typeof str !== "string") {
throw MHLError(`MHL.Error.Type.String`, { var: "str" }, { func, log: { str } });
}
bannerstr += localize(str, data);
return ui.notifications[type](bannerstr);
return ui.notifications[type](bannerstr, {console});
}

export function MHLError(
str,
data = {},
{ notify = null, prefix = "MacroHelperLibrary: ", log = {}, func = null } = {}
{ notify = null, prefix = "MHL | ", log = {}, func = null } = {}
) {
if (func && typeof func === "string") prefix += func;
return localizedError(str, data, { notify, prefix });
return localizedError(str, data, { notify, prefix, log });
}

// taken from https://stackoverflow.com/a/32728075, slightly modernized
/**
* Checks if value is empty. Deep-checks arrays and objects
* Note: isEmpty([]) == true, isEmpty({}) == true, isEmpty([{0:false},"",0]) == true, isEmpty({0:1}) == false
* @param value
* @returns {boolean}
*/
export function isEmpty(value) {
const isEmptyObject = (a) => {
if (!Array.isArray(a)) {
// it's an Object, not an Array
const hasNonempty = Object.keys(a).some((e) => !isEmpty(a[e]));
return hasNonempty ? false : isEmptyObject(Object.keys(a));
}
return !a.some((e) => !isEmpty(e));
};
return (
value == false ||
typeof value === "undefined" ||
value == null ||
(typeof value === "object" && isEmptyObject(value))
);
}

export function log(loggable, type = null, prefix = null) {
type ??= "debug";
if (!CONSOLE_TYPES.includes(type)) {
throw MHLError(`MHL.Error.LogTypes`, { types: BANNER_TYPES.join(", ") }, { func: "log: ", log: { type } });
}
prefix ??= "";
return console[type](prefix, loggable);
}

export function mhlog(loggable, type = null) {
return log(loggable, type, "MacroHelperLibrary |");
}
Loading

0 comments on commit 85f4048

Please sign in to comment.