Create the initial version of the TableManager class for configuring settings
This commit is contained in:
parent
4bfce858ef
commit
8a2d946b63
21 changed files with 718 additions and 115 deletions
|
|
@ -15,6 +15,7 @@ export class StatSidebar extends HandlebarsApplicationMixin(AbstractSidebarTab)
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
openStats: this.#openStats,
|
openStats: this.#openStats,
|
||||||
|
manageTables: this.#manageTables,
|
||||||
createTable: this.#createTable,
|
createTable: this.#createTable,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -39,7 +40,7 @@ export class StatSidebar extends HandlebarsApplicationMixin(AbstractSidebarTab)
|
||||||
const controls = {
|
const controls = {
|
||||||
openStats: { label: `View Stats`, action: `openStats` },
|
openStats: { label: `View Stats`, action: `openStats` },
|
||||||
createTable: { label: `Create New Table`, action: `createTable` },
|
createTable: { label: `Create New Table`, action: `createTable` },
|
||||||
manageTables: { label: `Manage Tables`, action: `` },
|
manageTables: { label: `Manage Tables`, action: `manageTables` },
|
||||||
manageData: { label: `Manage Data`, action: `` },
|
manageData: { label: `Manage Data`, action: `` },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -65,6 +66,12 @@ export class StatSidebar extends HandlebarsApplicationMixin(AbstractSidebarTab)
|
||||||
app.render({ force: true });
|
app.render({ force: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** @this {StatSidebar} */
|
||||||
|
static async #manageTables() {
|
||||||
|
const app = new CONFIG.stats.manager;
|
||||||
|
app.render({ force: true });
|
||||||
|
};
|
||||||
|
|
||||||
/** @this {StatSidebar} */
|
/** @this {StatSidebar} */
|
||||||
static async #createTable() {
|
static async #createTable() {
|
||||||
const app = new CONFIG.stats.creator;
|
const app = new CONFIG.stats.creator;
|
||||||
|
|
|
||||||
|
|
@ -117,8 +117,16 @@ export class StatsViewer extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||||
return ctx;
|
return ctx;
|
||||||
};
|
};
|
||||||
|
|
||||||
_selectedTable;
|
#_selectedTable = ``;
|
||||||
_selectedSubtable;
|
_selectedSubtable = ``;
|
||||||
|
get _selectedTable() {
|
||||||
|
return this.#_selectedTable;
|
||||||
|
};
|
||||||
|
set _selectedTable(val) {
|
||||||
|
this.#_selectedTable = val;
|
||||||
|
this._selectedSubtable = ``;
|
||||||
|
};
|
||||||
|
|
||||||
async #prepareTableSelectContext(ctx) {
|
async #prepareTableSelectContext(ctx) {
|
||||||
const tables = new Set();
|
const tables = new Set();
|
||||||
const subtables = {};
|
const subtables = {};
|
||||||
|
|
@ -264,15 +272,12 @@ export class StatsViewer extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||||
async #bindListener(event) {
|
async #bindListener(event) {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
const data = target.dataset;
|
const data = target.dataset;
|
||||||
|
|
||||||
const binding = data.bind;
|
const binding = data.bind;
|
||||||
if (!binding || !Object.hasOwn(this, binding)) {
|
|
||||||
Logger.debug(`Skipping change for element with binding "${binding}"`);
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
Logger.log(`updating ${binding} value to ${target.value}`);
|
if (import.meta.env.DEV) {
|
||||||
this[binding] = target.value;
|
Logger.debug(`updating ${binding} value to ${target.value}`);
|
||||||
|
}
|
||||||
|
Reflect.set(this, binding, target.value);
|
||||||
this.render();
|
this.render();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { BucketTypes } from "../utils/validateValue.mjs";
|
import { BucketTypes } from "../utils/buckets.mjs";
|
||||||
import { createDiceTable } from "../utils/databases/utils.mjs";
|
import { createDiceTable } from "../utils/databases/utils.mjs";
|
||||||
import { filePath } from "../consts.mjs";
|
import { filePath } from "../consts.mjs";
|
||||||
import { Logger } from "../utils/Logger.mjs";
|
import { Logger } from "../utils/Logger.mjs";
|
||||||
|
|
|
||||||
236
module/Apps/TableManager.mjs
Normal file
236
module/Apps/TableManager.mjs
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
import { diceSizeSorter } from "../utils/sorters/diceSize.mjs";
|
||||||
|
import { filePath } from "../consts.mjs";
|
||||||
|
import { Logger } from "../utils/Logger.mjs";
|
||||||
|
import { smallToLarge } from "../utils/sorters/smallToLarge.mjs";
|
||||||
|
|
||||||
|
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
|
||||||
|
const { isEmpty } = foundry.utils;
|
||||||
|
|
||||||
|
export class TableManager extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||||
|
// #region Options
|
||||||
|
static DEFAULT_OPTIONS = {
|
||||||
|
tag: `form`,
|
||||||
|
classes: [
|
||||||
|
__ID__,
|
||||||
|
`TableManager`,
|
||||||
|
],
|
||||||
|
window: {
|
||||||
|
title: `Table Manager`,
|
||||||
|
frame: true,
|
||||||
|
positioned: true,
|
||||||
|
resizable: true,
|
||||||
|
minimizable: true,
|
||||||
|
contentClasses: [`st-scrollable`],
|
||||||
|
controls: [
|
||||||
|
// Add action for deleting the table
|
||||||
|
],
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
width: 475,
|
||||||
|
height: 440,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
submitOnChange: false,
|
||||||
|
closeOnSubmit: false,
|
||||||
|
handler: this.#submit,
|
||||||
|
},
|
||||||
|
actions: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
static PARTS = {
|
||||||
|
tableSelect: {
|
||||||
|
template: filePath(`templates/Apps/common/tableSelect.hbs`),
|
||||||
|
},
|
||||||
|
buckets: {
|
||||||
|
template: filePath(`templates/Apps/TableManager/buckets/empty.hbs`),
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
template: filePath(`templates/Apps/TableManager/submit.hbs`),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
_configureRenderOptions(options) {
|
||||||
|
const table = CONFIG.stats.db.getTable(this.activeTableID);
|
||||||
|
|
||||||
|
let bucketType = table?.buckets?.type ?? `empty`;
|
||||||
|
this.constructor.PARTS.buckets = {
|
||||||
|
template: filePath(`templates/Apps/TableManager/buckets/${bucketType}.hbs`),
|
||||||
|
};
|
||||||
|
|
||||||
|
super._configureRenderOptions(options);
|
||||||
|
};
|
||||||
|
// #endregion Options
|
||||||
|
|
||||||
|
// #region Selected Table
|
||||||
|
#_selectedTable = ``;
|
||||||
|
_selectedSubtable = ``;
|
||||||
|
get _selectedTable() {
|
||||||
|
return this.#_selectedTable;
|
||||||
|
};
|
||||||
|
set _selectedTable(val) {
|
||||||
|
this.#_selectedTable = val;
|
||||||
|
this._selectedSubtable = ``;
|
||||||
|
};
|
||||||
|
|
||||||
|
get activeTableID() {
|
||||||
|
if (this._selectedSubtable) {
|
||||||
|
return `${this._selectedTable}/${this._selectedSubtable}`;
|
||||||
|
}
|
||||||
|
return this._selectedTable;
|
||||||
|
};
|
||||||
|
// #endregion Selected Table
|
||||||
|
|
||||||
|
// #region Lifecycle
|
||||||
|
async render({ userUpdated, ...opts } = {}) {
|
||||||
|
if (userUpdated) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await super.render(opts);
|
||||||
|
};
|
||||||
|
|
||||||
|
async _onRender(context, options) {
|
||||||
|
await super._onRender(context, options);
|
||||||
|
|
||||||
|
const elements = this.element
|
||||||
|
.querySelectorAll(`[data-bind]`);
|
||||||
|
for (const input of elements) {
|
||||||
|
input.addEventListener(`change`, this.#bindListener.bind(this));
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async _onFirstRender(context, options) {
|
||||||
|
await super._onFirstRender(context, options);
|
||||||
|
CONFIG.stats.db.addApp(this);
|
||||||
|
};
|
||||||
|
|
||||||
|
_tearDown() {
|
||||||
|
CONFIG.stats.db.removeApp(this);
|
||||||
|
return super._tearDown();
|
||||||
|
};
|
||||||
|
// #endregion Lifecycle
|
||||||
|
|
||||||
|
// #region Data Prep
|
||||||
|
async _preparePartContext(partId) {
|
||||||
|
const ctx = {
|
||||||
|
table: this._selectedTable,
|
||||||
|
subtable: this._selectedSubtable,
|
||||||
|
};
|
||||||
|
ctx.meta = {
|
||||||
|
idp: this.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (partId) {
|
||||||
|
case `tableSelect`: {
|
||||||
|
await this.#prepareTableSelectContext(ctx);
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
case `buckets`: {
|
||||||
|
await this.#prepareBucketContext(ctx);
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
Logger.log(partId, `context`, ctx);
|
||||||
|
};
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
async #prepareTableSelectContext(ctx) {
|
||||||
|
const tables = new Set();
|
||||||
|
const subtables = {};
|
||||||
|
|
||||||
|
for (const tableConfig of CONFIG.stats.db.getTables()) {
|
||||||
|
const [ table, subtable ] = tableConfig.name.split(`/`);
|
||||||
|
tables.add(table);
|
||||||
|
if (subtable?.length > 0) {
|
||||||
|
subtables[table] ??= [];
|
||||||
|
subtables[table].push(subtable);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableList = Array.from(tables);
|
||||||
|
ctx.table = this._selectedTable;
|
||||||
|
ctx.tables = tableList;
|
||||||
|
|
||||||
|
const subtableList = subtables[this._selectedTable];
|
||||||
|
|
||||||
|
// Sort the subtables to be sane
|
||||||
|
if (this._selectedTable === `Dice`) {
|
||||||
|
subtableList?.sort(diceSizeSorter);
|
||||||
|
} else {
|
||||||
|
subtableList?.sort(smallToLarge);
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.subtable = this._selectedSubtable;
|
||||||
|
ctx.subtables = subtableList;
|
||||||
|
};
|
||||||
|
|
||||||
|
async #prepareBucketContext(ctx) {
|
||||||
|
const table = CONFIG.stats.db.getTable(this.activeTableID);
|
||||||
|
if (!table) { return };
|
||||||
|
const type = table.buckets.type;
|
||||||
|
const capitalizedType = type[0].toUpperCase() + type.slice(1);
|
||||||
|
if (!this[`_prepare${capitalizedType}Context`]) { return };
|
||||||
|
this[`_prepare${capitalizedType}Context`](ctx, table);
|
||||||
|
};
|
||||||
|
|
||||||
|
async _prepareNumberContext(ctx, table) {
|
||||||
|
ctx.buckets = {
|
||||||
|
min: table.buckets.min,
|
||||||
|
max: table.buckets.max,
|
||||||
|
step: table.buckets.step,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async _prepareRangeContext(ctx, table) {
|
||||||
|
ctx.buckets = {
|
||||||
|
locked: this._selectedTable === `Dice` || table.buckets.locked,
|
||||||
|
min: table.buckets.min,
|
||||||
|
max: table.buckets.max,
|
||||||
|
step: table.buckets.step,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async _prepareStringContext(ctx, table) {
|
||||||
|
ctx.buckets = {
|
||||||
|
choices: [...table.buckets.choices],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
// #endregion Data Prep
|
||||||
|
|
||||||
|
// #region Actions
|
||||||
|
/**
|
||||||
|
* @param {Event} event
|
||||||
|
*/
|
||||||
|
async #bindListener(event) {
|
||||||
|
const target = event.target;
|
||||||
|
const data = target.dataset;
|
||||||
|
const binding = data.bind;
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
Logger.debug(`updating ${binding} value to ${target.value}`);
|
||||||
|
}
|
||||||
|
Reflect.set(this, binding, target.value);
|
||||||
|
this.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process form submission for the sheet.
|
||||||
|
* @this {DocumentSheetV2} The handler is called with the application as its bound scope
|
||||||
|
* @param {SubmitEvent} event The originating form submission event
|
||||||
|
* @param {HTMLFormElement} form The form element that was submitted
|
||||||
|
* @param {FormDataExtended} formData Processed data for the submitted form
|
||||||
|
* @param {object} [options] Additional options provided by a manual submit call. All options except `options.updateData` are forwarded along to _processSubmitData.
|
||||||
|
* @param {object} [options.updateData] Additional data passed in if this form is submitted manually which should be merged with prepared formData.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
static async #submit(_event, _form, formData, _options) {
|
||||||
|
if (isEmpty(formData.object)) {
|
||||||
|
ui.notifications.info(`Nothing to save`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CONFIG.stats.db.updateTable(this.activeTableID, formData.object);
|
||||||
|
};
|
||||||
|
// #endregion Actions
|
||||||
|
};
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
// Apps
|
// Apps
|
||||||
import { StatsViewer } from "./Apps/StatsViewer.mjs";
|
import { StatsViewer } from "./Apps/StatsViewer.mjs";
|
||||||
import { TableCreator } from "./Apps/TableCreator.mjs";
|
import { TableCreator } from "./Apps/TableCreator.mjs";
|
||||||
|
import { TableManager } from "./Apps/TableManager.mjs";
|
||||||
import { TestApp } from "./Apps/TestApp.mjs";
|
import { TestApp } from "./Apps/TestApp.mjs";
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
|
import { validateBucketConfig, validateValue } from "./utils/buckets.mjs";
|
||||||
import { filterPrivateRows } from "./utils/privacy.mjs";
|
import { filterPrivateRows } from "./utils/privacy.mjs";
|
||||||
import { validateValue } from "./utils/validateValue.mjs";
|
|
||||||
|
|
||||||
const { deepFreeze } = foundry.utils;
|
const { deepFreeze } = foundry.utils;
|
||||||
|
|
||||||
|
|
@ -18,10 +19,12 @@ Object.defineProperty(
|
||||||
TestApp,
|
TestApp,
|
||||||
StatsViewer,
|
StatsViewer,
|
||||||
TableCreator,
|
TableCreator,
|
||||||
|
TableManager,
|
||||||
},
|
},
|
||||||
utils: {
|
utils: {
|
||||||
filterPrivateRows,
|
filterPrivateRows,
|
||||||
validateValue,
|
validateValue,
|
||||||
|
validateBucketConfig,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
writable: false,
|
writable: false,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
import helpers from "../handlebarsHelpers/_index.mjs";
|
// Databases
|
||||||
import { Logger } from "../utils/Logger.mjs";
|
|
||||||
import { MemoryDatabase } from "../utils/databases/Memory.mjs";
|
import { MemoryDatabase } from "../utils/databases/Memory.mjs";
|
||||||
import { registerCustomComponents } from "../Apps/elements/_index.mjs";
|
import { UserFlagDatabase } from "../utils/databases/UserFlag.mjs";
|
||||||
import { registerMetaSettings } from "../settings/meta.mjs";
|
|
||||||
import { registerWorldSettings } from "../settings/world.mjs";
|
// Applications
|
||||||
import { StatSidebar } from "../Apps/StatSidebar.mjs";
|
import { StatSidebar } from "../Apps/StatSidebar.mjs";
|
||||||
import { StatsViewer } from "../Apps/StatsViewer.mjs";
|
import { StatsViewer } from "../Apps/StatsViewer.mjs";
|
||||||
import { TableCreator } from "../Apps/TableCreator.mjs";
|
import { TableCreator } from "../Apps/TableCreator.mjs";
|
||||||
import { UserFlagDatabase } from "../utils/databases/UserFlag.mjs";
|
import { TableManager } from "../Apps/TableManager.mjs";
|
||||||
|
|
||||||
|
// Misc Imports
|
||||||
|
import helpers from "../handlebarsHelpers/_index.mjs";
|
||||||
|
import { Logger } from "../utils/Logger.mjs";
|
||||||
|
import { registerCustomComponents } from "../Apps/elements/_index.mjs";
|
||||||
|
import { registerMetaSettings } from "../settings/meta.mjs";
|
||||||
|
import { registerWorldSettings } from "../settings/world.mjs";
|
||||||
|
|
||||||
Hooks.on(`init`, () => {
|
Hooks.on(`init`, () => {
|
||||||
Logger.debug(`Initializing`);
|
Logger.debug(`Initializing`);
|
||||||
|
|
@ -32,6 +38,7 @@ Hooks.on(`init`, () => {
|
||||||
db: UserFlagDatabase,
|
db: UserFlagDatabase,
|
||||||
viewer: StatsViewer,
|
viewer: StatsViewer,
|
||||||
creator: TableCreator,
|
creator: TableCreator,
|
||||||
|
manager: TableManager,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
|
|
|
||||||
123
module/utils/buckets.mjs
Normal file
123
module/utils/buckets.mjs
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { Logger } from "./Logger.mjs";
|
||||||
|
|
||||||
|
const { deepClone } = foundry.utils;
|
||||||
|
const { StringField, NumberField } = foundry.data.fields;
|
||||||
|
|
||||||
|
export const BucketTypes = {
|
||||||
|
STRING: `string`,
|
||||||
|
NUMBER: `number`,
|
||||||
|
RANGE: `range`,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} value The value to validate
|
||||||
|
* @param {BucketConfig} options The bucket config for the table
|
||||||
|
* @returns Whether or not the value is valid for the table
|
||||||
|
*/
|
||||||
|
export function validateValue(value, options) {
|
||||||
|
/** @type {BucketConfig} */
|
||||||
|
let opts = deepClone(options);
|
||||||
|
|
||||||
|
const validator = validators[opts.type];
|
||||||
|
if (validator == null) {
|
||||||
|
Logger.error(`Failed to find type validator for: ${opts.type}`);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Disallow function choices if present
|
||||||
|
if (typeof opts.choices === `function`) {
|
||||||
|
delete opts.choices;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get ride of properties that aren't part of the data fields
|
||||||
|
delete opts.type;
|
||||||
|
delete opts.locked;
|
||||||
|
|
||||||
|
validator.transformOptions(opts);
|
||||||
|
|
||||||
|
const field = new validator.field(opts);
|
||||||
|
const error = field.validate(value);
|
||||||
|
|
||||||
|
// DataFields return a class instance on error, or void when valid.
|
||||||
|
return !error;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {BucketConfig} config The bucket config for the table
|
||||||
|
* @returns {BucketConfig} The transformed bucket config
|
||||||
|
*/
|
||||||
|
export function validateBucketConfig(config) {
|
||||||
|
/** @type {BucketConfig} */
|
||||||
|
let conf = deepClone(config);
|
||||||
|
|
||||||
|
const validator = validators[conf.type];
|
||||||
|
if (validator == null) {
|
||||||
|
Logger.error(`Failed to find type validator for: ${conf.type}`);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Disallow function choices if present
|
||||||
|
if (typeof conf.choices === `function`) {
|
||||||
|
Logger.error(`Choices cannot be a function in a table's buckets configuraion`);
|
||||||
|
delete conf.choices;
|
||||||
|
};
|
||||||
|
|
||||||
|
validator.validateConfig(conf);
|
||||||
|
|
||||||
|
return conf;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validators = {
|
||||||
|
[BucketTypes.STRING]: {
|
||||||
|
field: StringField,
|
||||||
|
transformOptions: (opts) => {
|
||||||
|
opts.nullable = false;
|
||||||
|
opts.trim = true;
|
||||||
|
opts.blank = false;
|
||||||
|
},
|
||||||
|
validateConfig: (config) => {
|
||||||
|
if (config.choices.length === 0) {
|
||||||
|
delete config.choices;
|
||||||
|
config[`-=choices`] = null;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[BucketTypes.NUMBER]: {
|
||||||
|
field: NumberField,
|
||||||
|
transformOptions: transformNumberFieldOptions,
|
||||||
|
validateConfig: (config) => {
|
||||||
|
if (config.step != null && config.min == null) {
|
||||||
|
delete config.step;
|
||||||
|
config[`-=step`] = null;
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
config.min != null
|
||||||
|
&& config.max != null
|
||||||
|
&& config.min > config.max
|
||||||
|
) {
|
||||||
|
throw new Error(`"min" must be less than "max"`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[BucketTypes.RANGE]: {
|
||||||
|
field: NumberField,
|
||||||
|
transformOptions: transformNumberFieldOptions,
|
||||||
|
validateConfig: (config) => {
|
||||||
|
if (config.min == null) {
|
||||||
|
throw new Error(`"min" must be defined for range buckets`);
|
||||||
|
};
|
||||||
|
if (config.max == null) {
|
||||||
|
throw new Error(`"max" must be defined for range buckets`);
|
||||||
|
};
|
||||||
|
if (config.min > config.max) {
|
||||||
|
throw new Error(`"min" must be less than "max"`);
|
||||||
|
}
|
||||||
|
config.step ??= 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function transformNumberFieldOptions(opts) {
|
||||||
|
opts.nullable = false;
|
||||||
|
opts.integer = true;
|
||||||
|
};
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { Database } from "./Database.mjs";
|
import { Database } from "./Database.mjs";
|
||||||
import { filterPrivateRows } from "../privacy.mjs";
|
import { filterPrivateRows } from "../privacy.mjs";
|
||||||
import { Logger } from "../Logger.mjs";
|
import { Logger } from "../Logger.mjs";
|
||||||
|
import { validateBucketConfig } from "../buckets.mjs";
|
||||||
|
|
||||||
const { randomID, mergeObject } = foundry.utils;
|
const { deleteProperty, diffObject, expandObject, mergeObject, randomID } = foundry.utils;
|
||||||
|
|
||||||
export class MemoryDatabase extends Database {
|
export class MemoryDatabase extends Database {
|
||||||
static #tables = {
|
static #tables = {
|
||||||
|
|
@ -10,6 +11,7 @@ export class MemoryDatabase extends Database {
|
||||||
name: `Dice/d10`,
|
name: `Dice/d10`,
|
||||||
buckets: {
|
buckets: {
|
||||||
type: `range`,
|
type: `range`,
|
||||||
|
locked: true,
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 10,
|
max: 10,
|
||||||
step: 1,
|
step: 1,
|
||||||
|
|
@ -23,6 +25,7 @@ export class MemoryDatabase extends Database {
|
||||||
name: `Dice/d20`,
|
name: `Dice/d20`,
|
||||||
buckets: {
|
buckets: {
|
||||||
type: `range`,
|
type: `range`,
|
||||||
|
locked: true,
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 20,
|
max: 20,
|
||||||
step: 1,
|
step: 1,
|
||||||
|
|
@ -36,6 +39,7 @@ export class MemoryDatabase extends Database {
|
||||||
name: `Dice/d100`,
|
name: `Dice/d100`,
|
||||||
buckets: {
|
buckets: {
|
||||||
type: `range`,
|
type: `range`,
|
||||||
|
locked: true,
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 100,
|
max: 100,
|
||||||
step: 1,
|
step: 1,
|
||||||
|
|
@ -45,11 +49,23 @@ export class MemoryDatabase extends Database {
|
||||||
stacked: true,
|
stacked: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"Count of Successes": {
|
"Successes Number": {
|
||||||
name: `Count of Successes`,
|
name: `Successes Number`,
|
||||||
buckets: {
|
buckets: {
|
||||||
type: `number`,
|
type: `number`,
|
||||||
min: 0,
|
min: 0,
|
||||||
|
},
|
||||||
|
graph: {
|
||||||
|
type: `bar`,
|
||||||
|
stacked: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Successes Range": {
|
||||||
|
name: `Successes Range`,
|
||||||
|
buckets: {
|
||||||
|
type: `range`,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
step: 1,
|
step: 1,
|
||||||
},
|
},
|
||||||
graph: {
|
graph: {
|
||||||
|
|
@ -61,9 +77,6 @@ export class MemoryDatabase extends Database {
|
||||||
name: `Type of Result`,
|
name: `Type of Result`,
|
||||||
buckets: {
|
buckets: {
|
||||||
type: `string`,
|
type: `string`,
|
||||||
trim: true, // forced true
|
|
||||||
blank: false, // forced false
|
|
||||||
textSearch: false, // forced false
|
|
||||||
choices: [`Normal`, `Popped Off`, `Downed`],
|
choices: [`Normal`, `Popped Off`, `Downed`],
|
||||||
},
|
},
|
||||||
graph: {
|
graph: {
|
||||||
|
|
@ -81,6 +94,26 @@ export class MemoryDatabase extends Database {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static getTableNames() {
|
||||||
|
const tables = new Set();
|
||||||
|
for (const tableID of Object.keys(this.#tables)) {
|
||||||
|
const [ targetTable ] = tableID.split(`/`, 2);
|
||||||
|
tables.add(targetTable);
|
||||||
|
};
|
||||||
|
return Array.from(tables);
|
||||||
|
};
|
||||||
|
|
||||||
|
static getSubtableNames(table) {
|
||||||
|
const subtables = new Set();
|
||||||
|
for (const tableID of Object.keys(this.#tables)) {
|
||||||
|
const [ targetTable, targetSubtable ] = tableID.split(`/`, 2);
|
||||||
|
if (targetTable === table) {
|
||||||
|
subtables.add(targetSubtable);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return Array.from(subtables);
|
||||||
|
};
|
||||||
|
|
||||||
/** @returns {Array<Table>} */
|
/** @returns {Array<Table>} */
|
||||||
static getTables() {
|
static getTables() {
|
||||||
return Object.values(this.#tables);
|
return Object.values(this.#tables);
|
||||||
|
|
@ -90,6 +123,39 @@ export class MemoryDatabase extends Database {
|
||||||
return this.#tables[tableID];
|
return this.#tables[tableID];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static async updateTable(tableID, changes) {
|
||||||
|
Logger.debug({tableID, changes});
|
||||||
|
const table = this.getTable(tableID);
|
||||||
|
if (!table) { return false };
|
||||||
|
|
||||||
|
// Bucket coercion in case called via the API
|
||||||
|
deleteProperty(changes, `name`);
|
||||||
|
deleteProperty(changes, `buckets.type`);
|
||||||
|
|
||||||
|
const diff = diffObject(
|
||||||
|
table,
|
||||||
|
expandObject(changes),
|
||||||
|
{ inner: true, deletionKeys: true },
|
||||||
|
);
|
||||||
|
if (Object.keys(diff).length === 0) { return false };
|
||||||
|
|
||||||
|
const updated = mergeObject(
|
||||||
|
table,
|
||||||
|
diff,
|
||||||
|
{ inplace: false, performDeletions: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
updated.buckets = validateBucketConfig(updated.buckets);
|
||||||
|
} catch (e) {
|
||||||
|
ui.notifications.error(e);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.#tables[tableID] = updated;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
static createRow(table, userID, row, { rerender = true } = {}) {
|
static createRow(table, userID, row, { rerender = true } = {}) {
|
||||||
if (!this.#tables[table]) { return };
|
if (!this.#tables[table]) { return };
|
||||||
this.#rows[userID] ??= {};
|
this.#rows[userID] ??= {};
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import { Logger } from "./Logger.mjs";
|
|
||||||
|
|
||||||
const { deepClone } = foundry.utils;
|
|
||||||
const { StringField, NumberField } = foundry.data.fields;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {unknown} value The value to validate
|
|
||||||
* @param {BucketConfig} options The bucket config for the table
|
|
||||||
* @returns Whether or not the value is valid for the table
|
|
||||||
*/
|
|
||||||
export function validateValue(value, options) {
|
|
||||||
/** @type {BucketConfig} */
|
|
||||||
let opts = deepClone(options);
|
|
||||||
if (validatorTypes[opts.type] == null) {
|
|
||||||
Logger.error(`Failed to find type validator for: ${opts.type}`);
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validator = validatorTypes[opts.type];
|
|
||||||
validator.transformOptions(opts);
|
|
||||||
|
|
||||||
const field = new validator.field(opts);
|
|
||||||
const error = field.validate(value);
|
|
||||||
|
|
||||||
// DataFields return a class instance on error, or void when valid.
|
|
||||||
return !error;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BucketTypes = {
|
|
||||||
STRING: `string`,
|
|
||||||
NUMBER: `number`,
|
|
||||||
RANGE: `range`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const validatorTypes = {
|
|
||||||
[BucketTypes.STRING]: {
|
|
||||||
field: StringField,
|
|
||||||
transformOptions: (opts) => {
|
|
||||||
delete opts.type;
|
|
||||||
opts.nullable = false;
|
|
||||||
opts.trim = true;
|
|
||||||
opts.blank = false;
|
|
||||||
if (typeof opts.choices === `function`) {
|
|
||||||
Logger.error(`Choices cannot be a function in a table's buckets configuraion`);
|
|
||||||
delete opts.choices;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[BucketTypes.NUMBER]: {
|
|
||||||
field: NumberField,
|
|
||||||
transformOptions: (opts) => {
|
|
||||||
delete opts.type;
|
|
||||||
opts.nullable = false;
|
|
||||||
opts.integer = true;
|
|
||||||
if (typeof opts.choices === `function`) {
|
|
||||||
Logger.error(`Choices cannot be a function in a table's buckets configuraion`);
|
|
||||||
delete opts.choices;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[BucketTypes.RANGE]: {
|
|
||||||
field: NumberField,
|
|
||||||
transformOptions: (opts) => {
|
|
||||||
delete opts.type;
|
|
||||||
opts.nullable = false;
|
|
||||||
opts.integer = true;
|
|
||||||
if (typeof opts.choices === `function`) {
|
|
||||||
Logger.error(`Choices cannot be a function in a table's buckets configuraion`);
|
|
||||||
delete opts.choices;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -3,16 +3,6 @@
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-application-part="tableSelect"] {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 1rem;
|
|
||||||
|
|
||||||
> div {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-application-part="dataFilters"] {
|
[data-application-part="dataFilters"] {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|
@ -25,10 +15,4 @@
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
63
public/styles/Apps/TableManager.css
Normal file
63
public/styles/Apps/TableManager.css
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
.stat-tracker.TableManager {
|
||||||
|
min-width: 400px;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: initial;
|
||||||
|
|
||||||
|
.window-content {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-application-part="buckets"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
height: auto;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-box {
|
||||||
|
border-width: 2px;
|
||||||
|
border-style: dashed;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
&.warning {
|
||||||
|
background: color(from var(--color-level-warning-bg) srgb r g b / 0.1);
|
||||||
|
border-color: var(--color-level-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.locked {
|
||||||
|
margin: -0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-color: var(--color-level-error);
|
||||||
|
background: color(from var(--color-level-error-bg) srgb r g b / 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 3fr);
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-fill {
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-bottom: -0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
public/styles/Apps/common.css
Normal file
21
public/styles/Apps/common.css
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
.stat-tracker {
|
||||||
|
[data-application-part="tableSelect"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st-scrollable {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
public/styles/elements/span.css
Normal file
5
public/styles/elements/span.css
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.stat-tracker span {
|
||||||
|
&.large {
|
||||||
|
font-size: var(--font-size-24);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
public/styles/elements/string-tags.css
Normal file
13
public/styles/elements/string-tags.css
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
.stat-tracker string-tags {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
> .tags {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
> button {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
@layer resets, elements, components, apps, exceptions;
|
@layer resets, elements, components, partials, apps, exceptions;
|
||||||
|
|
||||||
@import url("./elements/custom-multi-select.css") layer(elements);
|
@import url("./elements/custom-multi-select.css") layer(elements);
|
||||||
|
@import url("./elements/string-tags.css") layer(elements);
|
||||||
|
@import url("./elements/span.css") layer(elements);
|
||||||
|
|
||||||
|
@import url("./Apps/common.css") layer(partials);
|
||||||
|
|
||||||
@import url("./Apps/StatsViewer.css") layer(apps);
|
@import url("./Apps/StatsViewer.css") layer(apps);
|
||||||
@import url("./Apps/StatsSidebar.css") layer(apps);
|
@import url("./Apps/StatsSidebar.css") layer(apps);
|
||||||
@import url("./Apps/TableCreator.css") layer(apps);
|
@import url("./Apps/TableCreator.css") layer(apps);
|
||||||
|
@import url("./Apps/TableManager.css") layer(apps);
|
||||||
|
|
|
||||||
7
public/templates/Apps/TableManager/buckets/empty.hbs
Normal file
7
public/templates/Apps/TableManager/buckets/empty.hbs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<div class="alert-box warning center">
|
||||||
|
{{#if table}}
|
||||||
|
<span class="large">Select a Subtable</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="large">Select a Table</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
57
public/templates/Apps/TableManager/buckets/number.hbs
Normal file
57
public/templates/Apps/TableManager/buckets/number.hbs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
<div class="{{#if buckets.locked}}alert-box locked{{/if}}">
|
||||||
|
{{#if buckets.locked}}
|
||||||
|
<p class="center">
|
||||||
|
This bucket configuration has been locked, preventing editing
|
||||||
|
of the settings.
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="{{meta.idp}}-min">
|
||||||
|
Minimum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="{{meta.idp}}-min"
|
||||||
|
type="number"
|
||||||
|
name="buckets.min"
|
||||||
|
value="{{ buckets.min }}"
|
||||||
|
{{disabled buckets.locked}}
|
||||||
|
>
|
||||||
|
<p class="hint">
|
||||||
|
The minimum allowed value. Leave empty for no minimum.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="{{meta.idp}}-max">
|
||||||
|
Maximum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="{{meta.idp}}-max"
|
||||||
|
type="number"
|
||||||
|
name="buckets.max"
|
||||||
|
value="{{ buckets.max }}"
|
||||||
|
{{disabled buckets.locked}}
|
||||||
|
>
|
||||||
|
<p class="hint">
|
||||||
|
The maximum allowed value. Leave empty for no maximum.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="{{meta.idp}}-step">
|
||||||
|
Step
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="{{meta.idp}}-min"
|
||||||
|
type="number"
|
||||||
|
name="buckets.step"
|
||||||
|
value="{{ buckets.step }}"
|
||||||
|
minimum="1"
|
||||||
|
placeholder="1"
|
||||||
|
{{disabled buckets.locked}}
|
||||||
|
>
|
||||||
|
<p class="hint">
|
||||||
|
The step value, if a minimum is not provided this will not be
|
||||||
|
saved and the existing value will be removed from the saved
|
||||||
|
configuration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
50
public/templates/Apps/TableManager/buckets/range.hbs
Normal file
50
public/templates/Apps/TableManager/buckets/range.hbs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<div class="{{#if buckets.locked}}alert-box locked{{/if}}">
|
||||||
|
{{#if buckets.locked}}
|
||||||
|
<p class="center">
|
||||||
|
This bucket configuration has been locked, preventing editing
|
||||||
|
of the settings.
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="{{meta.idp}}-min">
|
||||||
|
Minimum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="{{meta.idp}}-min"
|
||||||
|
type="number"
|
||||||
|
name="buckets.min"
|
||||||
|
value="{{ buckets.min }}"
|
||||||
|
required
|
||||||
|
{{disabled buckets.locked}}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="{{meta.idp}}-max">
|
||||||
|
Maximum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="{{meta.idp}}-max"
|
||||||
|
type="number"
|
||||||
|
name="buckets.max"
|
||||||
|
value="{{ buckets.max }}"
|
||||||
|
required
|
||||||
|
{{disabled buckets.locked}}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="{{meta.idp}}-step">
|
||||||
|
Step
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="{{meta.idp}}-step"
|
||||||
|
type="number"
|
||||||
|
name="buckets.step"
|
||||||
|
value="{{ buckets.step }}"
|
||||||
|
required
|
||||||
|
{{disabled buckets.locked}}
|
||||||
|
>
|
||||||
|
<p class="hint">
|
||||||
|
The size of the step between values within the range.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
17
public/templates/Apps/TableManager/buckets/string.hbs
Normal file
17
public/templates/Apps/TableManager/buckets/string.hbs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<div class="{{#if buckets.locked}}alert-box locked{{/if}}">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="">
|
||||||
|
Valid Options
|
||||||
|
</label>
|
||||||
|
<string-tags
|
||||||
|
name="buckets.choices"
|
||||||
|
value="{{ buckets.choices }}"
|
||||||
|
></string-tags>
|
||||||
|
<p class="hint">
|
||||||
|
These are the only values that are allowed to be provided to
|
||||||
|
the database and are case-sensitive. Leave this empty to
|
||||||
|
allow any string to be valid. Empty strings are never valid and
|
||||||
|
extra spaces are removed the start and end of the options.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
5
public/templates/Apps/TableManager/submit.hbs
Normal file
5
public/templates/Apps/TableManager/submit.hbs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
id="{{meta.idp}}-selected-table"
|
id="{{meta.idp}}-selected-table"
|
||||||
data-bind="_selectedTable"
|
data-bind="_selectedTable"
|
||||||
>
|
>
|
||||||
|
<option value="">Select a Table</option>
|
||||||
{{ st-options table tables }}
|
{{ st-options table tables }}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
id="{{meta.idp}}-selected-subtable"
|
id="{{meta.idp}}-selected-subtable"
|
||||||
data-bind="_selectedSubtable"
|
data-bind="_selectedSubtable"
|
||||||
>
|
>
|
||||||
|
<option value="">Select a Subtable</option>
|
||||||
{{ st-options subtable subtables }}
|
{{ st-options subtable subtables }}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue