Skip to content

feat: overhaul preset definitions#3931

Merged
Julusian merged 25 commits intomainfrom
feat/presets-restructure
Feb 14, 2026
Merged

feat: overhaul preset definitions#3931
Julusian merged 25 commits intomainfrom
feat/presets-restructure

Conversation

@Julusian
Copy link
Member

@Julusian Julusian commented Feb 1, 2026

closes #2843
closes #3937

see bitfocus/companion-module-base#182

Allows for:

Screencast.From.2026-02-01.21-41-17.mp4

Summary by CodeRabbit

  • New Features

    • Presets can be instantiated with per-instance variable values (allowing multiple variant instances and accurate previews)
    • Template presets generate value combinations automatically
    • Fuzzy search added for finding presets
  • UI/Style Changes

    • Presets reorganized into sections and groups with collapsible section UI
    • Improved preset previews and drag‑and‑drop that reflect provided variable values

@coderabbitai
Copy link

coderabbitai bot commented Feb 1, 2026

📝 Walkthrough

Walkthrough

Presets were reworked: conversion now returns both a presets Map and UI sections; controls and IPC incorporate a variablesHash for variable-driven variants; local variable overrides can be injected; web UI migrated from category lists to section/group/template rendering with fuzzy search and updated drag payloads.

Changes

Cohort / File(s) Summary
Control identity & hashing
shared-lib/lib/ControlId.ts, shared-lib/lib/Util/Hash.ts, companion/lib/Controls/ControlTypes/Button/Preset.ts
Added variablesHash to preset control IDs and storage path; added createStableObjectHash; constructors and ParseControlId updated to include/extract variablesHash.
Controls controller & preview
companion/lib/Controls/Controller.ts, companion/lib/Preview/Graphics.ts, companion/lib/Controls/ControlsTrpcRouter.ts
getOrCreatePresetControl and preview TRPC accept variableValues; controller computes stable hash, injects overridden locals, and passes variablesHash when creating/retrieving controls; TRPC schemas/inputs updated.
Variable injection & utils
companion/lib/Variables/Util.ts, companion/lib/Instance/Connection/Thread/PresetUtils.ts
Exported injectOverriddenLocalVariableValues added; replaceAllVariables gains a preserve-set parameter; option-to-expression conversions toggled flag from false→true in two calls.
Preset conversion & storage (core)
companion/lib/Instance/Connection/Thread/Presets.ts, companion/lib/Instance/Connection/PresetsLegacy.ts, companion/lib/Instance/Definitions.ts
Replaced per-item converter with ConvertPresetDefinitions returning { presets: Map, uiPresets }; internal storage switched to ReadonlyMap and UI sections; APIs updated to accept maps and uiDefinitions; grouping/template logic added.
IPC & handlers
companion/lib/Instance/Connection/IpcTypesNew.ts, companion/lib/Instance/Connection/ChildHandlerLegacy.ts, companion/lib/Instance/Connection/ChildHandlerNew.ts, companion/lib/Instance/Connection/Thread/HostContext.ts
IPC message shapes changed: presets as record/map plus uiPresets; handlers and host context updated to call/forward the new converter and accept two-part output.
Preview/control model conversion
companion/lib/Instance/Definitions.ts, companion/lib/Controls/ControlTypes/Button/Preset.ts
convertPresetToControlModel/preview now accept variableValues, clone localVariables and apply injected overrides when building control models.
Web UI: presets UI & drag data
webui/src/Buttons/Presets/PresetDefinitionsStore.tsx, webui/src/Buttons/Presets/PresetDragItem.tsx, webui/src/Buttons/Presets/PresetsSectionsList.tsx, webui/src/Buttons/Presets/PresetSectionCollapse.tsx, webui/src/Buttons/Presets/Presets.tsx, webui/src/Buttons/Presets/PresetsConnectionList.tsx, webui/src/Buttons/Presets/fuzzyMatch.ts, webui/src/Buttons/ButtonInfiniteGrid.tsx
UI migrated from category-based lists to section/group/template model; new PresetsSectionsList and PresetSectionCollapse components and fuzzyMatch util added; drag payloads include variableValues; preset store shape changed to record-of-sections.
Removed UI components
webui/src/Buttons/Presets/PresetButtonsCollapse.tsx, webui/src/Buttons/Presets/PresetsCategoryList.tsx
Deleted legacy category/collapse components; replaced by section/group-based components.
Shared model & types
shared-lib/lib/Model/Presets.ts, shared-lib/lib/Model/Options.ts
Unified/expanded preset and UI types (UIPresetSection, group/template types, unified PresetDefinition); widened options typing to accept preset option values and undefined in exprVal.
Module/version pins
package.json, shared-lib/lib/ModuleApiVersionCheck.ts
Updated nightly resolution hashes in package.json and bumped MODULE_BASE_VERSIONS entry to new nightly build.
Tests
companion/test/Variables/replace.test.ts, companion/test/Instance/Definitions.test.ts
Updated tests to new replaceAllVariables preserve-set signature and to Map-based setPresetDefinitions; added helpers and variable-injection test cases.

Poem

🌱 Sections rise where categories fell,
A hash keeps variant stories well,
Local vars slip into each view,
Fuzzy finds the button you knew,
Thanks — this patch grows the preset dell.

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (47 files):

⚔️ companion/lib/Controls/ControlTypes/Button/Preset.ts (content)
⚔️ companion/lib/Controls/Controller.ts (content)
⚔️ companion/lib/Controls/ControlsTrpcRouter.ts (content)
⚔️ companion/lib/Instance/Connection/ChildHandlerLegacy.ts (content)
⚔️ companion/lib/Instance/Connection/ChildHandlerNew.ts (content)
⚔️ companion/lib/Instance/Connection/IpcTypesNew.ts (content)
⚔️ companion/lib/Instance/Connection/PresetsLegacy.ts (content)
⚔️ companion/lib/Instance/Connection/Thread/HostContext.ts (content)
⚔️ companion/lib/Instance/Connection/Thread/PresetUtils.ts (content)
⚔️ companion/lib/Instance/Connection/Thread/Presets.ts (content)
⚔️ companion/lib/Instance/Definitions.ts (content)
⚔️ companion/lib/Preview/Graphics.ts (content)
⚔️ companion/lib/Variables/Util.ts (content)
⚔️ companion/test/Instance/Definitions.test.ts (content)
⚔️ companion/test/Variables/replace.test.ts (content)
⚔️ package.json (content)
⚔️ shared-lib/lib/ControlId.ts (content)
⚔️ shared-lib/lib/Model/Options.ts (content)
⚔️ shared-lib/lib/Model/Presets.ts (content)
⚔️ shared-lib/lib/ModuleApiVersionCheck.ts (content)
⚔️ webui/src/Buttons/ButtonInfiniteGrid.tsx (content)
⚔️ webui/src/Buttons/EditButton/SelectButtonTypeDropdown.tsx (content)
⚔️ webui/src/Buttons/Presets/PresetDefinitionsStore.tsx (content)
⚔️ webui/src/Buttons/Presets/PresetDragItem.tsx (content)
⚔️ webui/src/Buttons/Presets/Presets.tsx (content)
⚔️ webui/src/Buttons/Presets/PresetsConnectionList.tsx (content)
⚔️ webui/src/Components/DropdownInputField.tsx (content)
⚔️ webui/src/Components/ExpressionInputField.tsx (content)
⚔️ webui/src/Components/MultiDropdownInputField.tsx (content)
⚔️ webui/src/Components/TextInputField.tsx (content)
⚔️ webui/src/Components/index.tsx (content)
⚔️ webui/src/Connections/ConnectionList/ConnectionList.tsx (content)
⚔️ webui/src/Controls/Components/EntityChangeConnection.tsx (content)
⚔️ webui/src/Controls/InternalModuleField.tsx (content)
⚔️ webui/src/Controls/LocalVariablesStore.tsx (content)
⚔️ webui/src/Instances/useModuleVersionSelectOptions.tsx (content)
⚔️ webui/src/Resources/Expression.monarch.ts (content)
⚔️ webui/src/Surfaces/EditPanelConfigField.tsx (content)
⚔️ webui/src/Surfaces/Instances/SurfaceInstanceList/SurfaceInstanceList.tsx (content)
⚔️ webui/src/Surfaces/Remote/RemoteSurfaces/AddRemoteSurfaceButton.tsx (content)
⚔️ webui/src/Surfaces/Remote/RemoteSurfaces/RemoteSurfacesList.tsx (content)
⚔️ webui/src/scss/_button-grid.scss (content)
⚔️ webui/src/scss/_collapsible-tree.scss (content)
⚔️ webui/src/scss/_common.scss (content)
⚔️ webui/src/scss/_instances.scss (content)
⚔️ webui/src/types/monaco-augmentations.d.ts (content)
⚔️ yarn.lock (content)

These conflicts must be resolved before merging into main.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: overhaul preset definitions' clearly describes the main change—a comprehensive restructuring of how presets are defined and organized throughout the codebase.
Linked Issues check ✅ Passed The PR successfully implements the core requirements from both linked issues: support for richer preset structures (#2843) with preset sections/groups, and multi-level grouping (#3937) through the new section/group model.
Out of Scope Changes check ✅ Passed All changes are directly related to the preset definition overhaul. Updates to package.json and module versions support the implementation; refactoring of preset handling across components is in scope.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
webui/src/Buttons/Presets/PresetsSectionsList.tsx (2)

21-21: Small naming mismatch between inner function and export 👋

Hey, nice work on this component! Just a small thing: the inner function is named PresetsCategoryList (the old name) while the export is PresetsSectionsList. This won't cause any bugs, but it'll show as "PresetsCategoryList" in React DevTools, which could be a bit confusing during debugging.

💡 Suggested fix
-export const PresetsSectionsList = observer(function PresetsCategoryList({
+export const PresetsSectionsList = observer(function PresetsSectionsList({

44-46: Consider passing keywords as an array instead of joining 🔍

The fuzzyMatch utility already accepts string[] as a target (see fuzzyMatch.ts lines 4-22), which tests each keyword individually. Joining with ' ' concatenates them into one string, so a search query could accidentally fuzzy-match across a keyword boundary. It's a subtle difference and may not matter in practice, but passing the array directly would be slightly more accurate.

This applies in a few places: lines 46, 54, 60, 74, and 81.

💡 Example for line 46
-					const sectionMatchesSearch =
-						!searchQuery || fuzzyMatch(searchQuery, section.name, section.description, section.keywords?.join(' '))
+					const sectionMatchesSearch =
+						!searchQuery || fuzzyMatch(searchQuery, section.name, section.description, section.keywords)
companion/test/Instance/Definitions.test.ts (1)

1261-1279: Consider clarifying the expected behavior for empty presets in init 📝

The comment on line 1273 says "conn1 has empty UI definitions, implementation returns it as {}" — this is a bit ambiguous about whether this is the desired behavior or just the current behavior. If an empty {} for conn1 is intentional (so the UI knows the connection exists but has no presets), it might be worth a brief comment explaining why it's included rather than omitted. Totally optional, just a thought!

companion/lib/Instance/Connection/PresetsLegacy.ts (1)

46-53: Input mutation on preset.id ⚠️

Hey there! On line 49, preset.id is being assigned directly on the input object. Since rawPresets comes from the caller, this mutates the caller's data. It might be fine if the caller doesn't care, but it could lead to surprising side effects if the same array is referenced elsewhere.

A quick alternative would be to use a local variable for the id:

💡 Suggested tweak
 	rawPresets.forEach((preset, i) => {
 		if (preset.type !== 'button') return
 
-		if (!preset.id) preset.id = `_tmp_id_${i}`
-
-		const convertedPreset = ConvertPresetDefinition(logger, connectionId, connectionUpgradeIndex, preset.id, preset)
+		const presetId = preset.id || `_tmp_id_${i}`
+
+		const convertedPreset = ConvertPresetDefinition(logger, connectionId, connectionUpgradeIndex, presetId, preset)
 		if (convertedPreset) presets.set(convertedPreset.id, convertedPreset)
 	})

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@dnmeid
Copy link
Member

dnmeid commented Feb 2, 2026

Maybe a good moment to bring back two long standing feature requests about presets:

  1. Having a sort-name property, that is used for alphabetical sorting first if present. Could be string or number and if two presets have the same sortName the name is the second sorting criterium.
  2. Possibility of organizing feedbacks hierachically. This request was partly solved with the text presets definition, but text presets are only able to provide headers and sections, not a real (collapsible) hierarchy.
    The idea is to make the category property more powerful. If it is a regular string this is the name of the category. If it is an object like {name: 'Category Name', description: 'In this category are only important presets', sortName: '1'}, this will be the category but with additional metadata. If it is an array like ['Category Level 1', 'Category Name'], the preset will be in a folder named "Category Name" which itself is in a folder named "Category Level 1". Each folder/category can have subfolders/subcategories and presets. If the array contains objets like ['Category Level 1', {name: 'Category Name', description: 'In this category are only important presets', sortName: '1'}], the logic is like before. But since every preset could possibly declare competing metadata for the same category name, only the latest declared value will win.

So both of those changes would be completely backwards compatible and give a very easy upgrade path for module developers if they want to enhance preset organization. This would also bring the regular button presets to the same level of text presets but with less effort at declaration.

@Julusian
Copy link
Member Author

Julusian commented Feb 2, 2026

Having a sort-name property, that is used for alphabetical sorting first if present.

With this change, it goes back to relying on the order the presets/sections/groups are provided in

Possibility of organizing feedbacks hierachically. This request was partly solved with the text presets definition, but text presets are only able to provide headers and sections, not a real (collapsible) hierarchy.

This adds a single additonal level of groups, replacing the current text definiiton with the name+descritpion of a group.

I realised that the text definition type needs to go away for searching to be sane, otherwise we will need to either discard the text nodes early (loosing the context), or many searches will be left with just the text nodes.

Each folder/category can have subfolders/subcategories and presets.

I almost did do an unlimited depth for these, but I dont think we actually want that from a UX perspective. I'm not sure that even the groups I have added would want to collapse, but maybe they could (perhaps automatically when they grow above some threshold of number of contents). I dont think that going further than that will be nice to use.

So both of those changes would be completely backwards compatible

This is a decision to make here; Do we want something backwards compatible but harder to interpret and understand (both on companion and module dev side), or a breaking change that gives a chance to clean it up?

The other idea I had was to basically don't touch the current structure (other than require an unique id on each preset), but to have a second optional parameter that defines the new structure, referencing the array of presets by id.
But I also think that will be pretty tedious to construct safely (very easy to misuse ids).
This would have a different benefit of allowing the same preset to be shown at multiple points in the structure though, without being redefined, but I'm not sure that is worth the worse DX

Personally, I think that a bit of a breaking change would be beneficial. My hope is that by forcing devs to review their preset code a little, they are more likely to update it to newer functionality. Like the matrix group; which will cut down the volume of presets many provide dramatically.
And encourage them to consider if local variables could be used now that we are letting them do that.

A good example of why is there was an issue a couple of years back with bmd-videohub where it kept crashing for 288x288 videohubs, because of the volume of presets. It was trying to produce 288x288x3. All those could now be replaced with just a handful

@Julusian Julusian force-pushed the feat/presets-restructure branch from 87cdf3b to 63f460c Compare February 10, 2026 20:52
@socket-security
Copy link

socket-security bot commented Feb 10, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updated@​companion-module/​host@​0.1.0-1-nightly-feat-2-0-20260212-231442-a5da8ed ⏵ 0.1.0-1-nightly-feat-2-0-20260213-225453-440847a78 +110076 +195 +1100

View full report

# Conflicts:
#	package.json
#	shared-lib/lib/ModuleApiVersionCheck.ts
#	webui/src/Buttons/Presets/PresetsConnectionList.tsx
#	yarn.lock
@Julusian Julusian marked this pull request as ready for review February 13, 2026 23:05
@github-project-automation github-project-automation bot moved this to In Progress in Companion Plan Feb 13, 2026
@Julusian Julusian added this to the v4.3 milestone Feb 13, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
webui/src/Buttons/Presets/PresetDefinitionsStore.tsx (1)

33-41: ⚠️ Potential issue | 🟠 Major

MobX reactivity gap with in-place patching on plain Record values

Thanks for the thorough restructuring! 👍 I wanted to double-check one thing—the values stored in the observable map are plain Record objects, and when applyJsonPatchInPlace mutates them in place, MobX won't detect those nested changes since plain objects aren't deeply observable. This means observer components reading these presets (like PresetsSectionsList) won't re-render after a patch update, even though the @action decorator wraps the method.

The init and add cases trigger reactivity by calling .set() on the map, but patch only mutates the existing object reference without notifying observers.

Could you verify that downstream consumers correctly re-render after patches? If they don't, you'll want to either:

  1. Replace the map entry after patching to trigger reactivity: this.presets.set(update.connectionId, { ...currentPresets })
  2. Or wrap stored values with observable() so MobX tracks nested mutations
🧹 Nitpick comments (10)
companion/lib/Variables/Util.ts (1)

377-397: Looks solid! Just a friendly note about the mutation pattern. 🔧

injectOverriddenLocalVariableValues mutates localVariable.options.startup_value in place on the input array elements. This is fine if callers always pass cloned data (e.g., from structuredClone), but could surprise future callers who don't expect mutation. A brief JSDoc note mentioning "mutates the provided entities" would help keep things clear for other contributors.

Otherwise, the filtering logic and value wrapping with isExpressionOrValue/exprVal look correct. Nice work! 😊

webui/src/Buttons/Presets/fuzzyMatch.ts (1)

1-23: Nice little utility — clean and focused! 👍

A couple of small thoughts you might consider:

  1. The FUZZY_THRESHOLD of -5000 is quite permissive. This is probably intentional to keep results forgiving for preset discovery, but it might be worth adding a brief comment explaining the reasoning so future contributors understand the choice.

  2. Minor thought: if target is an empty array, Array.some() returns false, which seems like the right behavior — just noting it's handled correctly.

Welcome addition for the presets search feature!

webui/src/Buttons/Presets/PresetSectionCollapse.tsx (1)

162-169: Minor: empty <p> rendered when only name is set and description is undefined.

The parent condition grp.name || grp.description can be true when only name is set, resulting in an empty <p></p>. Not a big deal visually, but you could conditionally render it:

✨ Optional tweak
 function PresetText({ grp }: Readonly<PresetTextProps>) {
 	return (
 		<div className="mx-2 mt-2">
 			<h5>{grp.name}</h5>
-			<p>{grp.description}</p>
+			{grp.description && <p>{grp.description}</p>}
 		</div>
 	)
 }
webui/src/Buttons/Presets/PresetsSectionsList.tsx (3)

16-28: moduleInfo prop is declared but unused

Just a heads-up — moduleInfo is defined in the PresetsSectionsListProps interface (line 19) but isn't destructured or used anywhere in the component. If it's planned for future use, maybe a brief comment would help; otherwise, feel free to remove it to keep things tidy!


43-44: Consider passing keywords as an array to fuzzyMatch instead of joining

The fuzzyMatch utility already supports string[] inputs (see fuzzyMatch.ts lines 8-11), so joining keywords with ' ' loses the per-keyword matching granularity. For instance, searching "foo" would also match a partial hit on "foobar" when keywords are joined, while array matching would test each keyword individually.

This same pattern appears on lines 52, 58, 72, and 79.

💡 Suggested change (example for line 44)
-					const sectionMatchesSearch =
-						!searchQuery || fuzzyMatch(searchQuery, section.name, section.description, section.keywords?.join(' '))
+					const sectionMatchesSearch =
+						!searchQuery || fuzzyMatch(searchQuery, section.name, section.description, section.keywords)

38-103: allSections as a useComputed dependency defeats memoization

Since allSections is recomputed inline every render (lines 32-34), it produces a new array reference each time, causing useComputed to always recreate its computation. This isn't a bug — the filtering still works correctly — but it means useComputed doesn't provide any caching benefit here.

If you'd like to preserve memoization, you could either useMemo for allSections with the presets reference as a dependency, or move the entire computation (sorting + filtering) inside useComputed. Totally fine to leave as-is if perf isn't a concern at this scale though!

companion/lib/Controls/Controller.ts (1)

721-728: Minor: unreachable null check after new constructor

Line 728 (if (!newControl) return null) is unreachable since new ControlButtonPreset(...) always returns an instance. Not harmful, but you could remove it to reduce noise.

companion/lib/Instance/Definitions.ts (3)

323-341: Consider cloning the model when variableValues is null too, for defensive consistency.

When variableValues is non-null, you return a cloned model (Line 333-336). But on Line 331, returning definition.model directly exposes the stored definition to mutation by callers. If this was the same behavior before, it's a pre-existing concern, but since you're already cloning in one branch, it might be worth being consistent.

Not a blocker at all — just something to keep in mind if callers ever start mutating the returned model.


478-519: Input presets map values are mutated in-place before cloning — this may surprise callers.

Lines 478–519 mutate properties on the preset objects inside the presets parameter (e.g., preset.model.style.text, feedback.options, action.options). Then Line 532 stores a structuredClone.

Since the parameter is typed ReadonlyMap (implying no modification), the in-place mutation of the map's values can be surprising. When called from setPresetDefinitions, the caller's original data gets silently mutated. When called from updateVariablePrefixesForLabel, the previously-stored clone is mutated (less harmful since it's immediately replaced).

A cleaner approach would be to clone before mutating, so the input is never touched:

💡 Suggested approach
+		const presetsClone = structuredClone(presets)
+
-		for (const preset of presets.values()) {
+		for (const preset of presetsClone.values()) {
 			if (preset.model.style) {
 				preset.model.style.text = replaceAllVariables(preset.model.style.text, label, allowedSet)

Then on Line 532:

-		this.#presetDefinitions[connectionId] = structuredClone(presets)
+		this.#presetDefinitions[connectionId] = presetsClone

Not urgent if callers currently discard their references, but it'd be a nice safeguard against future surprises. 😊


546-549: Tiny nit: jsonPatch.compare always returns an array, so diff && is redundant.

jsonPatch.compare returns Operation[] — it's never null/undefined. The diff.length > 0 check alone is sufficient. Totally harmless as-is though!

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
companion/lib/Controls/Controller.ts (1)

728-728: Tiny nit: new always returns an instance (or throws)

Line 728 (if (!newControl) return null) is effectively dead code since calling new ControlButtonPreset(...) will always return an object or throw an exception — it can never be falsy. Not a big deal at all, but figured I'd mention it in case you're tidying up! 😊

webui/src/Buttons/Presets/PresetSectionCollapse.tsx (1)

162-169: Consider guarding against empty <h5> / <p> elements.

The parent already checks grp.name || grp.description, but inside PresetText both elements render unconditionally. If only one is set, you'd get an empty <h5> or <p> in the DOM — not a huge deal, but easy to tidy up if you'd like:

💅 Optional cleanup
 function PresetText({ grp }: Readonly<PresetTextProps>) {
 	return (
 		<div className="mx-2 mt-2">
-			<h5>{grp.name}</h5>
-			<p>{grp.description}</p>
+			{grp.name && <h5>{grp.name}</h5>}
+			{grp.description && <p>{grp.description}</p>}
 		</div>
 	)
 }

@Julusian Julusian merged commit ac123c9 into main Feb 14, 2026
18 checks passed
@Julusian Julusian deleted the feat/presets-restructure branch February 14, 2026 11:30
@github-project-automation github-project-automation bot moved this from In Progress to Done in Companion Plan Feb 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Support multi-level nested categories for presets [Feat] Preset text fields

2 participants

Comments