Feature/178 searchbar logic to items in character sheet (#209)

* REFACTOR: remove DhpApplicationMixin
REFACTOR: remove getEmbeddedDocument method from Item class
REFACTOR: remove prepareData method from Actor class
REFACTOR: remove _preUpdate method from Actor class

* REFACTOR: rename dhpItem to DHItem
REFACTOR: improvement Item#isInventoryItem getter
REFACTOR: simplify Item's createDialog static method.
REFACTOR: remove documentCreate template

* FEAT: add SearchFilter for character-sheet Inventory and DomainCards
FEAT: simplify the preparetion of inventory context

---------

Co-authored-by: Joaquin Pereyra <joaquinpereyra98@users.noreply.github.com>
This commit is contained in:
joaquinpereyra98 2025-06-28 23:44:57 -03:00 committed by GitHub
parent 7114a9e749
commit 7135716da9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 245 additions and 254 deletions

View file

@ -62,7 +62,7 @@ Hooks.once('init', () => {
CONFIG.Dice.rolls = [...CONFIG.Dice.rolls, ...[DHRoll, DualityRoll, D20Roll, DamageRoll]]; CONFIG.Dice.rolls = [...CONFIG.Dice.rolls, ...[DHRoll, DualityRoll, D20Roll, DamageRoll]];
CONFIG.MeasuredTemplate.objectClass = DhMeasuredTemplate; CONFIG.MeasuredTemplate.objectClass = DhMeasuredTemplate;
CONFIG.Item.documentClass = documents.DhpItem; CONFIG.Item.documentClass = documents.DHItem;
//Registering the Item DataModel //Registering the Item DataModel
CONFIG.Item.dataModels = models.items.config; CONFIG.Item.dataModels = models.items.config;

View file

@ -1,48 +0,0 @@
export default function DhpApplicationMixin(Base) {
return class DhpSheet extends Base {
static applicationType = 'sheets';
static documentType = '';
static get defaultOptions() {
return Object.assign(super.defaultOptions, {
classes: ['daggerheart', 'sheet', this.documentType],
template: `systems/${SYSTEM.id}/templates/${this.applicationType}/${this.documentType}.hbs`,
height: 'auto',
submitOnChange: true,
submitOnClose: false,
width: 450
});
}
/** @override */
get title() {
const { documentName, type, name } = this.object;
// const typeLabel = game.i18n.localize(CONFIG[documentName].typeLabels[type]);
const typeLabel = documentName;
return `[${typeLabel}] ${name}`;
}
// async _renderOuter() {
// const html = await super._renderOuter();
// // const overlaySrc = "systems/amia/assets/ThePrimordial.png";
// const overlay = `<div class="outer-render"></div>`
// $(html).find('.window-header').prepend(overlay);
// return html;
// }
activateListeners(html) {
super.activateListeners(html);
html.on('click', '[data-action]', this.#onClickAction.bind(this));
}
async #onClickAction(event) {
event.preventDefault();
const button = event.currentTarget;
const action = button.dataset.action;
return this._handleAction(action, event, button);
}
async _handleAction(action, event, button) {}
};
}

View file

@ -56,7 +56,6 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
resizable: true resizable: true
}, },
form: { form: {
handler: this.updateForm,
submitOnChange: true, submitOnChange: true,
closeOnSubmit: false closeOnSubmit: false
}, },
@ -218,6 +217,15 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
this._createContextMenues(); this._createContextMenues();
} }
/** @inheritDoc */
async _onRender(context, options) {
await super._onRender(context, options);
this._createSearchFilter();
}
/* -------------------------------------------- */
_createContextMenues() { _createContextMenues() {
const allOptions = { const allOptions = {
useItem: { useItem: {
@ -431,11 +439,105 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
return context; return context;
} }
static async updateForm(event, _, formData) { /* -------------------------------------------- */
await this.document.update(formData.object); /* Search Filter */
this.render(); /* -------------------------------------------- */
/**
* The currently active search filter.
* @type {foundry.applications.ux.SearchFilter}
*/
#search = {};
/**
* Track which item IDs are currently displayed due to a search filter.
* @type {{ inventory: Set<string>, loadout: Set<string> }}
*/
#filteredItems = {
inventory: new Set(),
loadout: new Set()
};
/**
* Create and initialize search filter instances for the inventory and loadout sections.
*
* Sets up two {@link foundry.applications.ux.SearchFilter} instances:
* - One for the inventory, which filters items in the inventory grid.
* - One for the loadout, which filters items in the loadout/card grid.
* @private
*/
_createSearchFilter() {
//Filters could be a application option if needed
const filters = [
{
key: 'inventory',
input: 'input[type="search"].search-inventory',
content: '[data-application-part="inventory"] .items-section',
callback: this._onSearchFilterInventory.bind(this)
},
{
key: 'loadout',
input: 'input[type="search"].search-loadout',
content: '[data-application-part="loadout"] .items-section',
callback: this._onSearchFilterCard.bind(this)
}
];
for (const { key, input, content, callback } of filters) {
const filter = new foundry.applications.ux.SearchFilter({
inputSelector: input,
contentSelector: content,
callback
});
filter.bind(this.element);
this.#search[key] = filter;
}
} }
/**
* Handle invetory items search and filtering.
* @param {KeyboardEvent} event The keyboard input event.
* @param {string} query The input search string.
* @param {RegExp} rgx The regular expression query that should be matched against.
* @param {HTMLElement} html The container to filter items from.
* @protected
*/
_onSearchFilterInventory(event, query, rgx, html) {
this.#filteredItems.inventory.clear();
for (const ul of html.querySelectorAll('.items-list')) {
for (const li of ul.querySelectorAll('.inventory-item')) {
const item = this.document.items.get(li.dataset.itemId);
const match = !query || foundry.applications.ux.SearchFilter.testQuery(rgx, item.name);
if (match) this.#filteredItems.inventory.add(item.id);
li.hidden = !match;
}
}
}
/**
* Handle card items search and filtering.
* @param {KeyboardEvent} event The keyboard input event.
* @param {string} query The input search string.
* @param {RegExp} rgx The regular expression query that should be matched against.
* @param {HTMLElement} html The container to filter items from.
* @protected
*/
_onSearchFilterCard(event, query, rgx, html) {
this.#filteredItems.loadout.clear();
const elements = html.querySelectorAll('.items-list .inventory-item, .card-list .card-item');
for (const li of elements) {
const item = this.document.items.get(li.dataset.itemId);
const match = !query || foundry.applications.ux.SearchFilter.testQuery(rgx, item.name);
if (match) this.#filteredItems.loadout.add(item.id);
li.hidden = !match;
}
}
/* -------------------------------------------- */
async mapFeatureType(data, configType) { async mapFeatureType(data, configType) {
return await Promise.all( return await Promise.all(
data.map(async x => { data.map(async x => {

View file

@ -9,7 +9,8 @@ export default class DHArmor extends BaseDataItem {
label: 'TYPES.Item.armor', label: 'TYPES.Item.armor',
type: 'armor', type: 'armor',
hasDescription: true, hasDescription: true,
isQuantifiable: true isQuantifiable: true,
isInventoryItem: true,
}); });
} }

View file

@ -7,6 +7,7 @@ import { actionsTypes } from '../action/_module.mjs';
* @property {string} type - The system type that this data model represents. * @property {string} type - The system type that this data model represents.
* @property {boolean} hasDescription - Indicates whether items of this type have description field * @property {boolean} hasDescription - Indicates whether items of this type have description field
* @property {boolean} isQuantifiable - Indicates whether items of this type have quantity field * @property {boolean} isQuantifiable - Indicates whether items of this type have quantity field
* @property {boolean} isInventoryItem- Indicates whether items of this type is a Inventory Item
*/ */
const fields = foundry.data.fields; const fields = foundry.data.fields;
@ -18,7 +19,8 @@ export default class BaseDataItem extends foundry.abstract.TypeDataModel {
label: 'Base Item', label: 'Base Item',
type: 'base', type: 'base',
hasDescription: false, hasDescription: false,
isQuantifiable: false isQuantifiable: false,
isInventoryItem: false,
}; };
} }

View file

@ -8,7 +8,8 @@ export default class DHConsumable extends BaseDataItem {
label: 'TYPES.Item.consumable', label: 'TYPES.Item.consumable',
type: 'consumable', type: 'consumable',
hasDescription: true, hasDescription: true,
isQuantifiable: true isQuantifiable: true,
isInventoryItem: true,
}); });
} }

View file

@ -8,7 +8,8 @@ export default class DHMiscellaneous extends BaseDataItem {
label: 'TYPES.Item.miscellaneous', label: 'TYPES.Item.miscellaneous',
type: 'miscellaneous', type: 'miscellaneous',
hasDescription: true, hasDescription: true,
isQuantifiable: true isQuantifiable: true,
isInventoryItem: true,
}); });
} }

View file

@ -12,10 +12,8 @@ export default class DHWeapon extends BaseDataItem {
type: 'weapon', type: 'weapon',
hasDescription: true, hasDescription: true,
isQuantifiable: true, isQuantifiable: true,
embedded: { isInventoryItem: true,
feature: 'featureTest' hasInitialAction: true,
},
hasInitialAction: true
}); });
} }

View file

@ -1,4 +1,4 @@
export { default as DhpActor } from './actor.mjs'; export { default as DhpActor } from './actor.mjs';
export { default as DhpItem } from './item.mjs'; export { default as DHItem } from './item.mjs';
export { default as DhpCombat } from './combat.mjs'; export { default as DhpCombat } from './combat.mjs';
export { default as DhActiveEffect } from './activeEffect.mjs'; export { default as DhActiveEffect } from './activeEffect.mjs';

View file

@ -17,14 +17,6 @@ export default class DhpActor extends Actor {
this.updateSource({ prototypeToken }); this.updateSource({ prototypeToken });
} }
prepareData() {
super.prepareData();
}
async _preUpdate(changed, options, user) {
super._preUpdate(changed, options, user);
}
async updateLevel(newLevel) { async updateLevel(newLevel) {
if (this.type !== 'character' || newLevel === this.system.levelData.level.changed) return; if (this.type !== 'character' || newLevel === this.system.levelData.level.changed) return;

View file

@ -1,14 +1,8 @@
export default class DhpItem extends Item { /**
/** @inheritdoc */ * Override and extend the basic Item implementation.
getEmbeddedDocument(embeddedName, id, { invalid = false, strict = false } = {}) { * @extends {foundry.documents.Item}
const systemEmbeds = this.system.constructor.metadata.embedded ?? {}; */
if (embeddedName in systemEmbeds) { export default class DHItem extends foundry.documents.Item {
const path = `system.${systemEmbeds[embeddedName]}`;
return foundry.utils.getProperty(this, path).get(id) ?? null;
}
return super.getEmbeddedDocument(embeddedName, id, { invalid, strict });
}
/** @inheritDoc */ /** @inheritDoc */
prepareEmbeddedDocuments() { prepareEmbeddedDocuments() {
super.prepareEmbeddedDocuments(); super.prepareEmbeddedDocuments();
@ -35,75 +29,59 @@ export default class DhpItem extends Item {
return data; return data;
} }
isInventoryItem() { /**
return ['weapon', 'armor', 'miscellaneous', 'consumable'].includes(this.type); * Determine if this item is classified as an inventory item based on its metadata.
* @returns {boolean} Returns `true` if the item is an inventory item.
*/
get isInventoryItem() {
return this.system.constructor.metadata.isInventoryItem ?? false;
} }
static async createDialog(data = {}, { parent = null, pack = null, ...options } = {}) { /** @inheritdoc */
const documentName = this.metadata.name; static async createDialog(data = {}, createOptions = {}, options = {}) {
const types = game.documentTypes[documentName].filter(t => t !== CONST.BASE_DOCUMENT_TYPE); const { folders, types, template, context = {}, ...dialogOptions } = options;
let collection;
if (!parent) {
if (pack) collection = game.packs.get(pack);
else collection = game.collections.get(documentName);
}
const folders = collection?._formatFolderSelectOptions() ?? [];
const label = game.i18n.localize(this.metadata.label);
const title = game.i18n.format('DOCUMENT.Create', { type: label });
const typeObjects = types.reduce((obj, t) => {
const label = CONFIG[documentName]?.typeLabels?.[t] ?? t;
obj[t] = { value: t, label: game.i18n.has(label) ? game.i18n.localize(label) : t };
return obj;
}, {});
// Render the document creation form if (types?.length === 0) {
const html = await foundry.applications.handlebars.renderTemplate( throw new Error('The array of sub-types to restrict to must not be empty.');
'systems/daggerheart/templates/sidebar/documentCreate.hbs', }
{
folders, const documentTypes = this.TYPES.filter(type => type !== 'base' && (!types || types.includes(type))).map(
name: data.name || game.i18n.format('DOCUMENT.New', { type: label }), type => {
folder: data.folder, const labelKey = CONFIG.Item?.typeLabels?.[type];
hasFolders: folders.length >= 1, const label = labelKey && game.i18n.has(labelKey) ? game.i18n.localize(labelKey) : type;
type: data.type || CONFIG[documentName]?.defaultType || typeObjects.armor,
types: { const isInventoryItem = CONFIG.Item.dataModels[type]?.metadata?.isInventoryItem;
Items: [typeObjects.armor, typeObjects.weapon, typeObjects.consumable, typeObjects.miscellaneous], const group =
Character: [ isInventoryItem === true
typeObjects.class, ? 'Inventory Items'
typeObjects.subclass, : isInventoryItem === false
typeObjects.ancestry, ? 'Character Items'
typeObjects.community, : 'Other';
typeObjects.feature,
typeObjects.domainCard return { value: type, label, group };
]
},
hasTypes: types.length > 1
} }
); );
// Render the confirmation dialog window if (!documentTypes.length) {
return Dialog.prompt({ throw new Error('No document types were permitted to be created.');
title: title, }
content: html,
label: title, const sortedTypes = documentTypes.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
callback: html => {
const form = html[0].querySelector('form'); return await super.createDialog(data, createOptions, {
const fd = new FormDataExtended(form); folders,
foundry.utils.mergeObject(data, fd.object, { inplace: true }); types,
if (!data.folder) delete data.folder; template,
if (types.length === 1) data.type = types[0]; context: { types: sortedTypes, ...context },
if (!data.name?.trim()) data.name = this.defaultName(); ...dialogOptions
return this.create(data, { parent, pack, renderSheet: true });
},
rejectClose: false,
options
}); });
} }
async selectActionDialog() { async selectActionDialog() {
const content = await foundry.applications.handlebars.renderTemplate( const content = await foundry.applications.handlebars.renderTemplate(
'systems/daggerheart/templates/views/actionSelect.hbs', 'systems/daggerheart/templates/views/actionSelect.hbs',
{ actions: this.system.actions } { actions: this.system.actions }
), ),
title = 'Select Action', title = 'Select Action',
type = 'div', type = 'div',
data = {}; data = {};
@ -142,8 +120,8 @@ export default class DhpItem extends Item {
this.type === 'ancestry' this.type === 'ancestry'
? game.i18n.localize('DAGGERHEART.Chat.FoundationCard.AncestryTitle') ? game.i18n.localize('DAGGERHEART.Chat.FoundationCard.AncestryTitle')
: this.type === 'community' : this.type === 'community'
? game.i18n.localize('DAGGERHEART.Chat.FoundationCard.CommunityTitle') ? game.i18n.localize('DAGGERHEART.Chat.FoundationCard.CommunityTitle')
: game.i18n.localize('DAGGERHEART.Chat.FoundationCard.SubclassFeatureTitle'), : game.i18n.localize('DAGGERHEART.Chat.FoundationCard.SubclassFeatureTitle'),
origin: origin, origin: origin,
img: this.img, img: this.img,
name: this.name, name: this.name,

View file

@ -4080,6 +4080,10 @@ div.daggerheart.views.multiclass {
.application.sheet.daggerheart.actor.dh-style.character .tab.inventory .search-section .search-bar input:placeholder { .application.sheet.daggerheart.actor.dh-style.character .tab.inventory .search-section .search-bar input:placeholder {
color: light-dark(#18162e50, #efe6d850); color: light-dark(#18162e50, #efe6d850);
} }
.application.sheet.daggerheart.actor.dh-style.character .tab.inventory .search-section .search-bar input::-webkit-search-cancel-button {
-webkit-appearance: none;
display: none;
}
.application.sheet.daggerheart.actor.dh-style.character .tab.inventory .search-section .search-bar .icon { .application.sheet.daggerheart.actor.dh-style.character .tab.inventory .search-section .search-bar .icon {
align-content: center; align-content: center;
height: 32px; height: 32px;

View file

@ -1,65 +1,70 @@
@import '../../utils/colors.less'; @import '../../utils/colors.less';
@import '../../utils/fonts.less'; @import '../../utils/fonts.less';
.application.sheet.daggerheart.actor.dh-style.character { .application.sheet.daggerheart.actor.dh-style.character {
.tab.inventory { .tab.inventory {
.search-section { .search-section {
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
.search-bar { .search-bar {
position: relative; position: relative;
color: light-dark(@dark-blue-50, @beige-50); color: light-dark(@dark-blue-50, @beige-50);
width: 100%; width: 100%;
padding-top: 5px; padding-top: 5px;
input { input {
border-radius: 50px; border-radius: 50px;
font-family: @font-body; font-family: @font-body;
background: light-dark(@dark-blue-10, @golden-10); background: light-dark(@dark-blue-10, @golden-10);
border: none; border: none;
outline: 2px solid transparent; outline: 2px solid transparent;
transition: all 0.3s ease; transition: all 0.3s ease;
padding: 0 20px; padding: 0 20px;
&:hover { &:hover {
outline: 2px solid light-dark(@dark, @golden); outline: 2px solid light-dark(@dark, @golden);
} }
&:placeholder { &:placeholder {
color: light-dark(@dark-blue-50, @beige-50); color: light-dark(@dark-blue-50, @beige-50);
} }
}
&::-webkit-search-cancel-button {
.icon { -webkit-appearance: none;
align-content: center; display: none;
height: 32px; }
position: absolute; }
right: 20px;
font-size: 16px; .icon {
z-index: 1; align-content: center;
color: light-dark(@dark-blue-50, @beige-50); height: 32px;
} position: absolute;
} right: 20px;
} font-size: 16px;
z-index: 1;
.items-section { color: light-dark(@dark-blue-50, @beige-50);
display: flex; }
flex-direction: column; }
gap: 10px; }
overflow-y: auto;
mask-image: linear-gradient(0deg, transparent 0%, black 5%, black 95%, transparent 100%); .items-section {
padding: 20px 0; display: flex;
height: 80%; flex-direction: column;
gap: 10px;
scrollbar-width: thin; overflow-y: auto;
scrollbar-color: light-dark(@dark-blue, @golden) transparent; mask-image: linear-gradient(0deg, transparent 0%, black 5%, black 95%, transparent 100%);
} padding: 20px 0;
height: 80%;
.currency-section {
display: flex; scrollbar-width: thin;
gap: 10px; scrollbar-color: light-dark(@dark-blue, @golden) transparent;
} }
}
} .currency-section {
display: flex;
gap: 10px;
}
}
}

View file

@ -8,7 +8,7 @@
<div class="icon"> <div class="icon">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass"></i>
</div> </div>
<input type="text" name="" id="" placeholder="Search..."> <input type="search" name="search" class="search-inventory" placeholder="Search...">
</div> </div>
<a><i class="fa-solid fa-filter"></i></a> <a><i class="fa-solid fa-filter"></i></a>
</div> </div>

View file

@ -8,7 +8,7 @@
<div class="icon"> <div class="icon">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass"></i>
</div> </div>
<input type="text" name="" id="" placeholder="Search..."> <input type="search" name="search" class="search-loadout" placeholder="Search...">
</div> </div>
<a><i class="fa-solid fa-filter"></i></a> <a><i class="fa-solid fa-filter"></i></a>
<button class="btn-toggle-view" data-action="toggleLoadoutView" data-value="{{this.abilities.loadout.listView}}"> <button class="btn-toggle-view" data-action="toggleLoadoutView" data-value="{{this.abilities.loadout.listView}}">

View file

@ -1,4 +1,4 @@
<li class="card-item" data-item-id="{{item.id}}"> <li class="card-item" data-item-id="{{item.id}}" data-item-id="{{item.id}}">
<img src="{{item.img}}" data-action="useItem" class="card-img" /> <img src="{{item.img}}" data-action="useItem" class="card-img" />
<div class="card-label"> <div class="card-label">
<div class="controls"> <div class="controls">

View file

@ -1,45 +0,0 @@
<form id="document-create" autocomplete="off">
<div class="form-group">
<label>{{localize "Name"}}</label>
<div class="form-fields">
<input type="text" name="name" placeholder="{{name}}" autofocus>
</div>
</div>
{{#if hasTypes}}
<div class="form-group">
<label>{{localize "Type"}}</label>
<div class="form-fields">
<select name="type">
{{#select type}}
{{#each types as |values key|}}
<optgroup label="{{key}}">
{{#each values}}
<option value="{{this.value}}">{{this.label}}</option>
{{/each}}
</optgroup>
{{/each}}
{{/select}}
</select>
</div>
</div>
{{/if}}
{{#if hasFolders}}
<div class="form-group">
<label>{{ localize "DOCUMENT.Folder" }}</label>
<div class="form-fields">
<select name="folder">
{{#select folder}}
<option value=""></option>
{{#each folders}}
<option value="{{ this.id }}">{{ this.name }}</option>
{{/each}}
{{/select}}
</select>
</div>
</div>
{{/if}}
{{{content}}}
</form>