From ea2d16f52e7385afc6d2e8ab9a04d9c37fe62c4a Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Tue, 30 Dec 2025 13:37:43 -0800 Subject: [PATCH 01/31] Forms: Add trigger etc - Add trigger: triggerOpenForm to auto open the form when you open the note - Rename getTemplateFormData to openTemplateForm - Remove Form Heading fallback in preview --- dwertheimer.Forms/plugin.json | 19 ++--- dwertheimer.Forms/src/NPTemplateForm.js | 79 ++++++++++++++----- .../src/components/FormPreview.jsx | 2 +- dwertheimer.Forms/src/formBuilderRouter.js | 10 +-- dwertheimer.Forms/src/index.js | 2 +- dwertheimer.Forms/src/windowManagement.js | 2 +- 6 files changed, 78 insertions(+), 36 deletions(-) diff --git a/dwertheimer.Forms/plugin.json b/dwertheimer.Forms/plugin.json index 118fdcf3a..b411ba50c 100644 --- a/dwertheimer.Forms/plugin.json +++ b/dwertheimer.Forms/plugin.json @@ -21,17 +21,18 @@ "name": "Open Template Form", "alias": [ "form", - "dialog" + "dialog", + "getTemplateFormData" ], "description": "Open form for template data entry which will be sent to a template for processing. Generally invoked from an xcallback", - "jsFunction": "getTemplateFormData", - "arguments": [ - { - "name": "templateTitle", - "type": "string", - "description": "Title of the template form to open (optional, if not provided user will be prompted to select)" - } - ] + "jsFunction": "openTemplateForm", + "arguments": ["Title of the template form to open (optional, if not provided user will be prompted to select)"] + }, + { + "name": "Open Template Form from trigger", + "description": "Open form for template data entry which will be sent to a template for processing. Generally invoked from an trigger. Looks for template details in the open note in Editor", + "jsFunction": "triggerOpenForm", + "hidden": true }, { "name": "Form Builder/Editor", diff --git a/dwertheimer.Forms/src/NPTemplateForm.js b/dwertheimer.Forms/src/NPTemplateForm.js index a77433184..69f16d867 100644 --- a/dwertheimer.Forms/src/NPTemplateForm.js +++ b/dwertheimer.Forms/src/NPTemplateForm.js @@ -80,7 +80,7 @@ function validateFormFields(formFields: Array): boolean { * @param {string} templateTitle - the title of the template to use * @returns {void} */ -export async function getTemplateFormData(templateTitle?: string): Promise { +export async function openTemplateForm(templateTitle?: string): Promise { try { let selectedTemplate // will be a filename if (templateTitle?.trim().length) { @@ -111,7 +111,7 @@ export async function getTemplateFormData(templateTitle?: string): Promise const note = await getNoteByFilename(selectedTemplate) if (note) { const fm = note.frontmatterAttributes - clo(fm, `getTemplateFormData fm=`) + clo(fm, `openTemplateForm fm=`) // Check processing method - determine from frontmatter or infer from receivingTemplateTitle (backward compatibility) const processingMethod = fm?.processingMethod || (fm?.receivingTemplateTitle || fm?.receivingtemplatetitle ? 'form-processor' : null) @@ -146,13 +146,13 @@ export async function getTemplateFormData(templateTitle?: string): Promise const formFieldsString: ?string = await loadCodeBlockFromNote(selectedTemplate, 'formfields', pluginJson.id, null) if (formFieldsString) { const errors = validateObjectString(formFieldsString) - logError(pluginJson, `getTemplateFormData: error validating form fields in ${selectedTemplate}, String:\n${formFieldsString}, `) - logError(pluginJson, `getTemplateFormData: errors: ${errors.join('\n')}`) + logError(pluginJson, `openTemplateForm: error validating form fields in ${selectedTemplate}, String:\n${formFieldsString}, `) + logError(pluginJson, `openTemplateForm: errors: ${errors.join('\n')}`) return } } - clo(formFields, `🎅🏼 DBWDELETE NPTemplating.getTemplateFormData formFields=`) - logDebug(pluginJson, `🎅🏼 DBWDELETE NPTemplating.getTemplateFormData formFields=\n${JSON.stringify(formFields, null, 2)}`) + clo(formFields, `🎅🏼 DBWDELETE NPTemplating.openTemplateForm formFields=`) + logDebug(pluginJson, `🎅🏼 DBWDELETE NPTemplating.openTemplateForm formFields=\n${JSON.stringify(formFields, null, 2)}`) } else { // Try to get raw string for error reporting const formFieldsString: ?string = await loadCodeBlockFromNote(selectedTemplate, 'formfields', pluginJson.id, null) @@ -161,37 +161,37 @@ export async function getTemplateFormData(templateTitle?: string): Promise formFields = parseObjectString(formFieldsString) if (!formFields) { const errors = validateObjectString(formFieldsString) - logError(pluginJson, `getTemplateFormData: error validating form fields in ${selectedTemplate}, String:\n${formFieldsString}, `) - logError(pluginJson, `getTemplateFormData: errors: ${errors.join('\n')}`) + logError(pluginJson, `openTemplateForm: error validating form fields in ${selectedTemplate}, String:\n${formFieldsString}, `) + logError(pluginJson, `openTemplateForm: errors: ${errors.join('\n')}`) return } } catch (error) { const errors = validateObjectString(formFieldsString) await showMessage( - `getTemplateFormData: There is an error in your form fields (most often a missing comma).\nJS Error: "${error.message}"\nCheck Plugin Console Log for more details.`, + `openTemplateForm: There is an error in your form fields (most often a missing comma).\nJS Error: "${error.message}"\nCheck Plugin Console Log for more details.`, ) - logError(pluginJson, `getTemplateFormData: error parsing form fields: ${error.message} String:\n${formFieldsString}`) - logError(pluginJson, `getTemplateFormData: errors: ${errors.join('\n')}`) + logError(pluginJson, `openTemplateForm: error parsing form fields: ${error.message} String:\n${formFieldsString}`) + logError(pluginJson, `openTemplateForm: errors: ${errors.join('\n')}`) return } } } } else { - logError(pluginJson, `getTemplateFormData: could not find form template: ${selectedTemplate}`) + logError(pluginJson, `openTemplateForm: could not find form template: ${selectedTemplate}`) return } } // Ensure we have a selectedTemplate before proceeding if (!selectedTemplate) { - logError(pluginJson, 'getTemplateFormData: No template selected') + logError(pluginJson, 'openTemplateForm: No template selected') return } // Get the note directly (bypassing getTemplateContent which assumes @Templates folder) const templateNote = await getNoteByFilename(selectedTemplate) if (!templateNote) { - logError(pluginJson, `getTemplateFormData: could not find form template note: ${selectedTemplate}`) + logError(pluginJson, `openTemplateForm: could not find form template note: ${selectedTemplate}`) return } @@ -201,14 +201,14 @@ export async function getTemplateFormData(templateTitle?: string): Promise if (templateNote.filename?.startsWith('%%NotePlanCloud%%')) { const teamspaceDetails = parseTeamspaceFilename(templateNote.filename || '') templateTeamspaceID = teamspaceDetails.teamspaceID || '' - logDebug(pluginJson, `getTemplateFormData: Template is in teamspace: ${templateTeamspaceID}`) + logDebug(pluginJson, `openTemplateForm: Template is in teamspace: ${templateTeamspaceID}`) } // Get template content directly from note (not through getTemplateContent which assumes @Templates) const templateData = templateNote.content || '' const templateFrontmatterAttributes = await NPTemplating.getTemplateAttributes(templateData) - clo(templateData, `getTemplateFormData templateData=`) - clo(templateFrontmatterAttributes, `getTemplateFormData templateFrontmatterAttributes=`) + clo(templateData, `openTemplateForm templateData=`) + clo(templateFrontmatterAttributes, `openTemplateForm templateFrontmatterAttributes=`) // Check processing method - determine from frontmatter or infer from receivingTemplateTitle (backward compatibility) const processingMethod = templateFrontmatterAttributes?.processingMethod || (templateFrontmatterAttributes?.receivingTemplateTitle ? 'form-processor' : null) @@ -264,7 +264,7 @@ export async function getTemplateFormData(templateTitle?: string): Promise // This ensures forms opened in a teamspace default to that teamspace for creating/loading notes if (templateTeamspaceID && !frontmatterAttributes.space) { frontmatterAttributes.space = templateTeamspaceID - logDebug(pluginJson, `getTemplateFormData: Setting default space to template's teamspace: ${templateTeamspaceID}`) + logDebug(pluginJson, `openTemplateForm: Setting default space to template's teamspace: ${templateTeamspaceID}`) } if (templateFrontmatterAttributes.formFields) { @@ -438,6 +438,7 @@ export async function openFormBuilder(templateTitle?: string): Promise { // formTitle is left blank by default - user can fill it in later launchLink: launchLink, formEditLink: formEditLink, + triggers: 'onOpen => dwertheimer.Forms.triggerOpenForm', width: '25%', height: '40%', x: 'center', @@ -650,7 +651,7 @@ export async function openFormBuilder(templateTitle?: string): Promise { /** * Opens the HTML+React window; Called after the form data has been generated - * @param {Object} argObj - the data to pass to the React Window (comes from templating "getTemplateFormData" command, a combination of the template frontmatter vars and formFields codeblock) + * @param {Object} argObj - the data to pass to the React Window (comes from templating "openTemplateForm" command, a combination of the template frontmatter vars and formFields codeblock) * - formFields: array (required) - the form fields to display * - windowTitle: string (optional) - the title of the window (defaults to 'Form') * - formTitle: string (optional) - the title of the form (inside the window) @@ -744,6 +745,46 @@ export async function openFormBrowser(_showFloating: boolean = false): Promise} + */ +export async function triggerOpenForm(): Promise { + try { + // Check if Editor.note exists + if (!Editor.note) { + logDebug(pluginJson, 'triggerOpenForm: No note is currently open in Editor') + return + } + + // Check if Editor.frontmatterAttributes exists and has type "template-form" + const frontmatterAttributes = Editor.frontmatterAttributes || {} + const noteType = frontmatterAttributes.type + + if (noteType !== 'template-form') { + logDebug(pluginJson, `triggerOpenForm: Note type is "${noteType || 'undefined'}", not "template-form". Skipping.`) + return + } + + // Get the note title + const noteTitle = Editor.note.title + if (!noteTitle) { + logError(pluginJson, 'triggerOpenForm: Note has type "template-form" but no title found') + await showMessage('Note has type "template-form" but no title found. Cannot open form.') + return + } + + logDebug(pluginJson, `triggerOpenForm: Opening template form with title: "${noteTitle}"`) + // Open the template form with the note's title + await openTemplateForm(noteTitle) + } catch (error) { + logError(pluginJson, `triggerOpenForm: Error: ${JSP(error)}`) + await showMessage(`Error opening form: ${error.message}`) + } +} + /** * Export testRequestHandlers for direct testing */ diff --git a/dwertheimer.Forms/src/components/FormPreview.jsx b/dwertheimer.Forms/src/components/FormPreview.jsx index 304081456..450018952 100644 --- a/dwertheimer.Forms/src/components/FormPreview.jsx +++ b/dwertheimer.Forms/src/components/FormPreview.jsx @@ -306,7 +306,7 @@ export function FormPreview({ {})} diff --git a/dwertheimer.Forms/src/formBuilderRouter.js b/dwertheimer.Forms/src/formBuilderRouter.js index 96efa1c88..4c91b9d5b 100644 --- a/dwertheimer.Forms/src/formBuilderRouter.js +++ b/dwertheimer.Forms/src/formBuilderRouter.js @@ -8,7 +8,7 @@ import pluginJson from '../plugin.json' import { handleRequest } from './requestHandlers' // For shared requests like getFolders, getNotes, getTeamspaces import { handleSaveRequest, handleCreateProcessingTemplate, handleOpenNote, handleCopyFormUrl, handleDuplicateForm } from './formBuilderHandlers' import { openFormBuilderWindow, FORMBUILDER_WINDOW_ID } from './windowManagement' -import { getTemplateFormData } from './NPTemplateForm' +import { openTemplateForm } from './NPTemplateForm' import { createRouter, type RequestResponse } from './routerUtils' import { closeWindowFromCustomId } from '@helpers/NPWindows' import { getNoteByFilename } from '@helpers/note' @@ -70,12 +70,12 @@ async function handleFormBuilderNonRequestAction(_actionType: string, data: any) closeWindowFromCustomId(windowId) } else if (actualActionType === 'openForm' && data?.templateTitle) { logDebug(pluginJson, `onFormBuilderAction: Opening form with templateTitle="${data.templateTitle}"`) - logDebug(pluginJson, `onFormBuilderAction: Calling getTemplateFormData with templateTitle="${data.templateTitle}"`) + logDebug(pluginJson, `onFormBuilderAction: Calling openTemplateForm with templateTitle="${data.templateTitle}"`) try { - await getTemplateFormData(data.templateTitle) - logDebug(pluginJson, `onFormBuilderAction: getTemplateFormData completed successfully`) + await openTemplateForm(data.templateTitle) + logDebug(pluginJson, `onFormBuilderAction: openTemplateForm completed successfully`) } catch (error) { - logError(pluginJson, `onFormBuilderAction: Error in getTemplateFormData: ${error.message}`) + logError(pluginJson, `onFormBuilderAction: Error in openTemplateForm: ${error.message}`) logError(pluginJson, `onFormBuilderAction: Error stack: ${error.stack || 'No stack trace'}`) throw error } diff --git a/dwertheimer.Forms/src/index.js b/dwertheimer.Forms/src/index.js index 9233757b7..7b1d96dd6 100644 --- a/dwertheimer.Forms/src/index.js +++ b/dwertheimer.Forms/src/index.js @@ -15,7 +15,7 @@ // So you need to add a line below for each function that you want NP to have access to. // Typically, listed below are only the top-level plug-in functions listed in plugin.json -export { getTemplateFormData, openFormBuilder, testRequestHandlers, openFormBrowser } from './NPTemplateForm' +export { openTemplateForm, openFormBuilder, testRequestHandlers, openFormBrowser, triggerOpenForm } from './NPTemplateForm' export { onFormSubmitFromHTMLView } from './formSubmitRouter' export { onFormBuilderAction } from './formBuilderRouter' export { onFormBrowserAction } from './formBrowserRouter' diff --git a/dwertheimer.Forms/src/windowManagement.js b/dwertheimer.Forms/src/windowManagement.js index 63d24bab4..9918f4740 100644 --- a/dwertheimer.Forms/src/windowManagement.js +++ b/dwertheimer.Forms/src/windowManagement.js @@ -235,7 +235,7 @@ export function getPluginData(argObj: Object): { [string]: mixed } { /** * Opens the HTML+React window; Called after the form data has been generated - * @param {Object} argObj - the data to pass to the React Window (comes from templating "getTemplateFormData" command, a combination of the template frontmatter vars and formFields codeblock) + * @param {Object} argObj - the data to pass to the React Window (comes from templating "openTemplateForm" command, a combination of the template frontmatter vars and formFields codeblock) * - formFields: array (required) - the form fields to display * - windowTitle: string (optional) - the title of the window (defaults to 'Form') * - formTitle: string (optional) - the title of the form (inside the window) From 8be7956bed13b53bcd97c882efcb847a88c10816 Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Tue, 30 Dec 2025 13:51:48 -0800 Subject: [PATCH 02/31] Add 'All Private + Spaces' option to SpaceChooser and improve FieldTypeSelector - Add includeAllOption prop to SpaceChooser component (default: false) - When enabled, adds 'All Private + Spaces' option that returns '__all__' - Add space-chooser to TSettingItemType union - Add checkbox in FieldEditor for space-chooser fields to enable includeAllOption - Add space-chooser to fieldTypes.js so it appears in field type selector - Add filter functionality to FieldTypeSelector with auto-focus - Filter searches across field type value, label, and description - Update dialogElementRenderer to pass through includeAllOption prop --- .../src/components/FieldEditor.jsx | 22 ++++++ .../src/components/FieldTypeSelector.jsx | 62 +++++++++++++++-- .../src/components/FormBuilder.css | 34 ++++++++++ .../src/components/fieldTypes.js | 1 + helpers/react/DynamicDialog/DynamicDialog.jsx | 5 +- helpers/react/DynamicDialog/SpaceChooser.jsx | 68 +++++++++++++++---- .../DynamicDialog/dialogElementRenderer.js | 1 + 7 files changed, 171 insertions(+), 22 deletions(-) diff --git a/dwertheimer.Forms/src/components/FieldEditor.jsx b/dwertheimer.Forms/src/components/FieldEditor.jsx index bee8b9eba..819ece87b 100644 --- a/dwertheimer.Forms/src/components/FieldEditor.jsx +++ b/dwertheimer.Forms/src/components/FieldEditor.jsx @@ -595,6 +595,28 @@ export function FieldEditor({ field, allFields, onSave, onCancel, requestFromPlu )} + {editedField.type === 'space-chooser' && ( + <> +
+ +
+ When enabled, adds an "All Private + Spaces" option that returns "__all__" when selected. This allows users to select all spaces at once. +
+
+ + )} + {editedField.type === 'event-chooser' && ( <>
diff --git a/dwertheimer.Forms/src/components/FieldTypeSelector.jsx b/dwertheimer.Forms/src/components/FieldTypeSelector.jsx index 8e8d25060..4368ca457 100644 --- a/dwertheimer.Forms/src/components/FieldTypeSelector.jsx +++ b/dwertheimer.Forms/src/components/FieldTypeSelector.jsx @@ -3,7 +3,7 @@ // FieldTypeSelector Component - Modal for selecting field type when adding new field //-------------------------------------------------------------------------- -import React, { type Node } from 'react' +import React, { useState, useEffect, useRef, useMemo, type Node } from 'react' import { FIELD_TYPES, type FieldTypeOption } from './fieldTypes.js' import { type TSettingItemType } from '@helpers/react/DynamicDialog/DynamicDialog.jsx' @@ -14,6 +14,39 @@ type FieldTypeSelectorProps = { } export function FieldTypeSelector({ isOpen, onSelect, onClose }: FieldTypeSelectorProps): Node { + const [filterText, setFilterText] = useState('') + const filterInputRef = useRef(null) + + // Focus filter input when modal opens + useEffect(() => { + if (isOpen && filterInputRef.current) { + // Use setTimeout to ensure the modal is fully rendered + setTimeout(() => { + if (filterInputRef.current) { + filterInputRef.current.focus() + } + }, 0) + } else if (!isOpen) { + // Clear filter when modal closes + setFilterText('') + } + }, [isOpen]) + + // Filter field types based on filter text (searches in value, label, and description) + const filteredFieldTypes = useMemo(() => { + if (!filterText.trim()) { + return FIELD_TYPES + } + const searchTerm = filterText.toLowerCase() + return FIELD_TYPES.filter((fieldType) => { + return ( + fieldType.value.toLowerCase().includes(searchTerm) || + fieldType.label.toLowerCase().includes(searchTerm) || + fieldType.description.toLowerCase().includes(searchTerm) + ) + }) + }, [filterText]) + if (!isOpen) return null const handleSelect = (fieldType: FieldTypeOption) => { @@ -25,18 +58,33 @@ export function FieldTypeSelector({ isOpen, onSelect, onClose }: FieldTypeSelect
e.stopPropagation()}>

Select Field Type

+
+ setFilterText(e.target.value)} + onClick={(e) => e.stopPropagation()} + /> +
- {FIELD_TYPES.map((fieldType) => ( -
handleSelect(fieldType)}> -
{fieldType.label}
-
{fieldType.description}
-
- ))} + {filteredFieldTypes.length === 0 ? ( +
No field types match your search.
+ ) : ( + filteredFieldTypes.map((fieldType) => ( +
handleSelect(fieldType)}> +
{fieldType.label}
+
{fieldType.description}
+
+ )) + )}
diff --git a/dwertheimer.Forms/src/components/FormBuilder.css b/dwertheimer.Forms/src/components/FormBuilder.css index f25665bad..26c92e47a 100644 --- a/dwertheimer.Forms/src/components/FormBuilder.css +++ b/dwertheimer.Forms/src/components/FormBuilder.css @@ -184,6 +184,13 @@ border-color: var(--np-theme-primary, #007aff); } +.field-type-no-results { + padding: 2rem; + text-align: center; + color: var(--np-theme-text-secondary, #999999); + font-style: italic; +} + .field-type-label { font-weight: 600; margin-bottom: 0.25rem; @@ -630,6 +637,7 @@ display: flex; justify-content: space-between; align-items: center; + gap: 1rem; padding: 1rem 1.5rem; border-bottom: 1px solid var(--np-theme-border, #e0e0e0); } @@ -637,6 +645,32 @@ .field-type-selector-header h3 { margin: 0; font-size: 1.25rem; + flex-shrink: 0; +} + +.field-type-selector-filter-wrapper { + flex: 1; + max-width: 300px; +} + +.field-type-selector-filter { + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 0.9rem; + border: 1px solid var(--np-theme-border, #e0e0e0); + border-radius: 4px; + background: var(--np-theme-background, #ffffff); + color: var(--np-theme-text, #000000); +} + +.field-type-selector-filter:focus { + outline: none; + border-color: var(--np-theme-primary, #007aff); + box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2); +} + +.field-type-selector-filter::placeholder { + color: var(--np-theme-text-secondary, #999999); } .field-type-selector-close { diff --git a/dwertheimer.Forms/src/components/fieldTypes.js b/dwertheimer.Forms/src/components/fieldTypes.js index 03c0ac1ea..231a61345 100644 --- a/dwertheimer.Forms/src/components/fieldTypes.js +++ b/dwertheimer.Forms/src/components/fieldTypes.js @@ -24,6 +24,7 @@ export const FIELD_TYPES: Array = [ { value: 'calendarpicker', label: 'Date Picker', description: 'Date selection calendar' }, { value: 'folder-chooser', label: 'Folder Chooser', description: 'Searchable folder selector' }, { value: 'note-chooser', label: 'Note Chooser', description: 'Searchable note selector' }, + { value: 'space-chooser', label: 'Space Chooser', description: 'Select a Space (Private or Teamspace)' }, { value: 'heading-chooser', label: 'Heading Chooser', description: 'Select a heading from a note (static or dynamic based on note-chooser)' }, { value: 'event-chooser', label: 'Event Chooser', description: 'Select a calendar event for a specific date' }, { value: 'markdown-preview', label: 'Markdown Preview', description: 'Display markdown content (static text, note by filename/title, or note from another field)' }, diff --git a/helpers/react/DynamicDialog/DynamicDialog.jsx b/helpers/react/DynamicDialog/DynamicDialog.jsx index 37ce36636..914b3e10b 100644 --- a/helpers/react/DynamicDialog/DynamicDialog.jsx +++ b/helpers/react/DynamicDialog/DynamicDialog.jsx @@ -49,6 +49,7 @@ export type TSettingItemType = | 'orderingPanel' | 'folder-chooser' | 'note-chooser' + | 'space-chooser' // Space (Private/Teamspace) chooser | 'heading-chooser' | 'event-chooser' // Calendar event chooser | 'form-state-viewer' // Read-only field that displays current form state as JSON @@ -111,6 +112,8 @@ export type TSettingItem = { sourceFolderKey?: string, // Value dependency: for note-chooser, key of a folder-chooser field to filter notes by folder // showValue option for SearchableChooser-based fields showValue?: boolean, // for folder-chooser, note-chooser, heading-chooser, dropdown-select-chooser: show the selected value below the input (default: false) + // space-chooser options + includeAllOption?: boolean, // for space-chooser, include "All Private + Spaces" option that returns "__all__" (default: false) staticHeadings?: Array, // for heading-chooser, static list of headings (if not depending on a note) // textarea options minRows?: number, // for textarea, minimum number of rows (default: 3) @@ -447,7 +450,7 @@ const DynamicDialog = ({ value: typeof item.key === 'undefined' ? '' : updatedSettings[item.key] ?? '', checked: typeof item.key === 'undefined' ? false : updatedSettings[item.key] === true, }, - disabled: (item.dependsOnKey || item.requiresKey) ? !stateOfControllingSetting(item) : false, + disabled: item.dependsOnKey || item.requiresKey ? !stateOfControllingSetting(item) : false, indent: Boolean(item.dependsOnKey || item.requiresKey), handleFieldChange, handleButtonClick, // Pass handleButtonClick diff --git a/helpers/react/DynamicDialog/SpaceChooser.jsx b/helpers/react/DynamicDialog/SpaceChooser.jsx index 1eeee2915..704560708 100644 --- a/helpers/react/DynamicDialog/SpaceChooser.jsx +++ b/helpers/react/DynamicDialog/SpaceChooser.jsx @@ -4,10 +4,9 @@ // Allows users to select a Space (Teamspace or Private) by typing to filter choices //-------------------------------------------------------------------------- -import React, { useState, useEffect, useMemo, useRef } from 'react' +import React, { useState, useEffect, useRef } from 'react' import SearchableChooser, { type ChooserConfig } from './SearchableChooser' import { logDebug, logError } from '@helpers/react/reactDev.js' -import { TEAMSPACE_FA_ICON } from '@helpers/teamspace.js' import { TEAMSPACE_ICON_COLOR } from '@helpers/NPnote.js' import { truncateText } from '@helpers/react/reactUtils.js' import './SpaceChooser.css' @@ -20,13 +19,14 @@ export type SpaceOption = { export type SpaceChooserProps = { label?: string, - value?: string, // The space ID (empty string for Private) - onChange: (spaceId: string) => void, // Callback with space ID (empty string for Private) + value?: string, // The space ID (empty string for Private, "__all__" for All) + onChange: (spaceId: string) => void, // Callback with space ID (empty string for Private, "__all__" for All) disabled?: boolean, compactDisplay?: boolean, placeholder?: string, requestFromPlugin?: (command: string, dataToSend?: any, timeout?: number) => Promise, showValue?: boolean, // If true, display the selected value below the input + includeAllOption?: boolean, // If true, include "All Private + Spaces" option that returns "__all__" } /** @@ -44,18 +44,30 @@ export function SpaceChooser({ placeholder = 'Type to search spaces...', requestFromPlugin, showValue = false, + includeAllOption = false, }: SpaceChooserProps): React$Node { const [spaces, setSpaces] = useState>([]) const [spacesLoaded, setSpacesLoaded] = useState(false) const [isLoading, setIsLoading] = useState(false) const requestFromPluginRef = useRef Promise>(requestFromPlugin) const isLoadingRef = useRef(false) // Track loading state to prevent concurrent loads + const includeAllOptionRef = useRef(includeAllOption) - // Update ref when requestFromPlugin changes + // Update refs when props change useEffect(() => { requestFromPluginRef.current = requestFromPlugin }, [requestFromPlugin]) + useEffect(() => { + // If includeAllOption changes and spaces are already loaded, we need to reload + if (includeAllOptionRef.current !== includeAllOption && spacesLoaded) { + includeAllOptionRef.current = includeAllOption + setSpacesLoaded(false) // Force reload to update the options list + } else { + includeAllOptionRef.current = includeAllOption + } + }, [includeAllOption, spacesLoaded]) + // Load spaces (teamspaces) from plugin const loadSpaces = async () => { const requestFn = requestFromPluginRef.current @@ -65,7 +77,7 @@ export function SpaceChooser({ } const loadStartTime = performance.now() - let isMounted = true + const isMounted = true // Track if component is still mounted (currently always true, but kept for future cleanup pattern) try { isLoadingRef.current = true setIsLoading(true) @@ -75,13 +87,20 @@ export function SpaceChooser({ const loadElapsed = performance.now() - loadStartTime logDebug('SpaceChooser', `[DIAG] loadSpaces COMPLETE: elapsed=${loadElapsed.toFixed(2)}ms`) - // Always include Private as the first option + // Always include Private as an option const privateOption: SpaceOption = { id: '', title: 'Private', isPrivate: true, } + // Optionally include "All Private + Spaces" option + const allOption: SpaceOption = { + id: '__all__', + title: 'All Private + Spaces', + isPrivate: false, // Not technically private, but we'll handle it specially in display functions + } + if (isMounted) { if (Array.isArray(teamspacesData)) { // Convert teamspaces to SpaceOption format @@ -91,8 +110,9 @@ export function SpaceChooser({ isPrivate: false, })) - // Combine Private + Teamspaces - setSpaces([privateOption, ...teamspaceOptions]) + // Combine options: All (if enabled) + Private + Teamspaces + const allOptions = includeAllOptionRef.current ? [allOption, privateOption, ...teamspaceOptions] : [privateOption, ...teamspaceOptions] + setSpaces(allOptions) setSpacesLoaded(true) logDebug('SpaceChooser', `Loaded ${teamspaceOptions.length} teamspaces + Private`) if (teamspaceOptions.length > 0) { @@ -104,22 +124,29 @@ export function SpaceChooser({ } } else { logError('SpaceChooser', `[DIAG] loadSpaces: Invalid response format, got:`, typeof teamspacesData, teamspacesData) - // Still set Private option even on error - setSpaces([privateOption]) + // Still set Private option even on error (and All if enabled) + const allOptions = includeAllOptionRef.current ? [allOption, privateOption] : [privateOption] + setSpaces(allOptions) setSpacesLoaded(true) } } } catch (error) { const loadElapsed = performance.now() - loadStartTime logError('SpaceChooser', `[DIAG] loadSpaces ERROR: elapsed=${loadElapsed.toFixed(2)}ms, error="${error.message}"`) - // Still set Private option even on error + // Still set Private option even on error (and All if enabled) if (isMounted) { const privateOption: SpaceOption = { id: '', title: 'Private', isPrivate: true, } - setSpaces([privateOption]) + const allOption: SpaceOption = { + id: '__all__', + title: 'All Private + Spaces', + isPrivate: false, + } + const allOptions = includeAllOptionRef.current ? [allOption, privateOption] : [privateOption] + setSpaces(allOptions) setSpacesLoaded(true) // Set to true to prevent infinite retries on error } } finally { @@ -144,6 +171,7 @@ export function SpaceChooser({ isLoadingRef.current = false } // Only depend on spacesLoaded, not requestFromPlugin to avoid infinite loops + // includeAllOption changes are handled by the separate useEffect above // eslint-disable-next-line react-hooks/exhaustive-deps }, [spacesLoaded]) @@ -161,6 +189,9 @@ export function SpaceChooser({ return space.title }, getOptionTitle: (space: SpaceOption) => { + if (space.id === '__all__') { + return 'All Private notes and all Teamspaces' + } return space.isPrivate ? 'Private notes (default)' : `Teamspace: ${space.title}` }, truncateDisplay: truncateText, @@ -179,19 +210,28 @@ export function SpaceChooser({ dropdownMaxLength: 80, getOptionIcon: (space: SpaceOption) => { // TEAMSPACE_FA_ICON is 'fa-regular fa-cube', we need just 'cube' for fa-solid + if (space.id === '__all__') { + return 'layer-group' // Icon representing "all" or multiple layers + } return space.isPrivate ? 'user' : 'cube' }, getOptionColor: (space: SpaceOption) => { + if (space.id === '__all__') { + return undefined // Use default color for "All" option + } return space.isPrivate ? undefined : TEAMSPACE_ICON_COLOR }, getOptionShortDescription: (space: SpaceOption) => { + if (space.id === '__all__') { + return 'All Private notes and Teamspaces' + } return space.isPrivate ? 'Your private notes' : 'Teamspace' }, } // Find the current space to get its display title const currentSpace = spaces.find((s) => s.id === value) || (value === '' ? spaces.find((s) => s.isPrivate) : null) - const displayValue = currentSpace ? currentSpace.title : value || 'Private' + const displayValue = currentSpace ? currentSpace.title : value === '__all__' ? 'All Private + Spaces' : value || 'Private' return (
) From a7e1de0ef7b0c6990fc32e4a415cbb6ba18d825b Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Tue, 30 Dec 2025 14:01:20 -0800 Subject: [PATCH 03/31] Fix FieldTypeSelector modal height to prevent shifting during filtering - Set fixed height (600px) on field-type-selector-modal to prevent shrinking - Remove min-height constraints from content and list areas - Use proper flexbox scrolling with min-height: 0 - Add note about handling '__all__' value in FieldEditor help text --- dwertheimer.Forms/src/components/FieldEditor.jsx | 1 + dwertheimer.Forms/src/components/FormBuilder.css | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/dwertheimer.Forms/src/components/FieldEditor.jsx b/dwertheimer.Forms/src/components/FieldEditor.jsx index 819ece87b..efe3727a1 100644 --- a/dwertheimer.Forms/src/components/FieldEditor.jsx +++ b/dwertheimer.Forms/src/components/FieldEditor.jsx @@ -612,6 +612,7 @@ export function FieldEditor({ field, allFields, onSave, onCancel, requestFromPlu
When enabled, adds an "All Private + Spaces" option that returns "__all__" when selected. This allows users to select all spaces at once. + NOTE: whatever is receiving the value needs to handle the "__all__" value appropriately.
diff --git a/dwertheimer.Forms/src/components/FormBuilder.css b/dwertheimer.Forms/src/components/FormBuilder.css index 26c92e47a..7363849ba 100644 --- a/dwertheimer.Forms/src/components/FormBuilder.css +++ b/dwertheimer.Forms/src/components/FormBuilder.css @@ -166,8 +166,9 @@ display: flex; flex-direction: column; gap: 0.5rem; - max-height: 60vh; overflow-y: auto; + flex: 1; + min-height: 0; } .field-type-option { @@ -627,6 +628,7 @@ border-radius: 8px; width: 90%; max-width: 600px; + height: 600px; max-height: 90vh; display: flex; flex-direction: column; @@ -691,6 +693,9 @@ flex: 1; padding: 1.5rem; overflow-y: auto; + display: flex; + flex-direction: column; + min-height: 0; } .field-type-selector-footer { From cef10d36a107e32a113d1a0105e248d59e99f202 Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Tue, 30 Dec 2025 15:54:20 -0800 Subject: [PATCH 04/31] Add option for 2nd line shortDescription on choosers --- .../src/components/FieldEditor.jsx | 75 +++++++++++++++++++ .../src/components/FormBrowserView.css | 21 ++++++ .../src/components/FormBrowserView.jsx | 11 ++- dwertheimer.Forms/src/formBrowserHandlers.js | 59 +++++++++++---- helpers/react/DynamicDialog/DynamicDialog.jsx | 2 + helpers/react/DynamicDialog/EventChooser.jsx | 3 + helpers/react/DynamicDialog/FolderChooser.jsx | 3 + .../react/DynamicDialog/HeadingChooser.jsx | 3 + helpers/react/DynamicDialog/NoteChooser.jsx | 3 + .../react/DynamicDialog/SearchableChooser.css | 59 +++++++++++++++ .../react/DynamicDialog/SearchableChooser.jsx | 60 +++++++++++++++ helpers/react/DynamicDialog/SpaceChooser.jsx | 3 + .../DynamicDialog/dialogElementRenderer.js | 5 ++ 13 files changed, 291 insertions(+), 16 deletions(-) diff --git a/dwertheimer.Forms/src/components/FieldEditor.jsx b/dwertheimer.Forms/src/components/FieldEditor.jsx index efe3727a1..146da255d 100644 --- a/dwertheimer.Forms/src/components/FieldEditor.jsx +++ b/dwertheimer.Forms/src/components/FieldEditor.jsx @@ -525,6 +525,21 @@ export function FieldEditor({ field, allFields, onSave, onCancel, requestFromPlu
Exclude teamspace folders from the list of folders
+
+ +
When enabled, displays the short description (e.g., folder path, space name) on a second line below the label
+
)} @@ -592,6 +607,21 @@ export function FieldEditor({ field, allFields, onSave, onCancel, requestFromPlu
Include teamspace notes in the list
+
+ +
When enabled, displays the short description (e.g., folder path, space name) on a second line below the label
+
)} @@ -615,6 +645,21 @@ export function FieldEditor({ field, allFields, onSave, onCancel, requestFromPlu NOTE: whatever is receiving the value needs to handle the "__all__" value appropriately. +
+ +
When enabled, displays the short description (e.g., folder path, space name) on a second line below the label
+
)} @@ -872,6 +917,21 @@ export function FieldEditor({ field, allFields, onSave, onCancel, requestFromPlu )} )} +
+ +
When enabled, displays the short description (e.g., calendar name) on a second line below the label
+
)} @@ -970,6 +1030,21 @@ export function FieldEditor({ field, allFields, onSave, onCancel, requestFromPlu Include headings in Archive section +
+ +
When enabled, displays the short description on a second line below the label
+
)} diff --git a/dwertheimer.Forms/src/components/FormBrowserView.css b/dwertheimer.Forms/src/components/FormBrowserView.css index eb9d617ee..3a17f6cbf 100644 --- a/dwertheimer.Forms/src/components/FormBrowserView.css +++ b/dwertheimer.Forms/src/components/FormBrowserView.css @@ -184,6 +184,14 @@ gap: 0.5rem; } +.form-browser-list-item-content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + .form-browser-list-item-label { flex: 1; min-width: 0; @@ -192,6 +200,19 @@ white-space: nowrap; } +.form-browser-list-item-space { + font-size: 0.75rem; + opacity: 0.6; + color: var(--np-theme-text-secondary, #666666); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.form-browser-list-item.selected .form-browser-list-item-space { + color: rgba(255, 255, 255, 0.8); +} + .form-browser-list-item-actions { display: flex; gap: 0.25rem; diff --git a/dwertheimer.Forms/src/components/FormBrowserView.jsx b/dwertheimer.Forms/src/components/FormBrowserView.jsx index 0ffcd3069..b497ba010 100644 --- a/dwertheimer.Forms/src/components/FormBrowserView.jsx +++ b/dwertheimer.Forms/src/components/FormBrowserView.jsx @@ -19,6 +19,8 @@ type FormTemplate = { label: string, value: string, filename: string, + spaceId?: string, // Empty string for Private, teamspace ID for teamspaces + spaceTitle?: string, // "Private" or teamspace title } type FormBrowserViewProps = { @@ -724,6 +726,8 @@ export function FormBrowserView({ compactDisplay={true} requestFromPlugin={requestFromPlugin} showValue={false} + includeAllOption={true} + shortDescriptionOnLine2={true} />
@@ -804,7 +808,12 @@ export function FormBrowserView({ }} tabIndex={0} > - {template.label} +
+ {template.label} + {selectedSpace === '__all__' && template.spaceTitle && ( + {template.spaceTitle} + )} +
e.stopPropagation()}>
) @@ -577,6 +579,7 @@ export function renderItem({ optionAddTopAndBottom={optionAddTopAndBottom} includeArchive={includeArchive} showValue={item.showValue ?? false} + shortDescriptionOnLine2={item.shortDescriptionOnLine2 ?? false} />
) @@ -650,6 +653,7 @@ export function renderItem({ eventFilterRegex={eventFilterRegex} includeReminders={includeReminders} reminderLists={reminderLists} + shortDescriptionOnLine2={item.shortDescriptionOnLine2 ?? false} /> ) @@ -771,6 +775,7 @@ export function renderItem({ requestFromPlugin={requestFromPlugin} showValue={item.showValue ?? false} includeAllOption={item.includeAllOption ?? false} + shortDescriptionOnLine2={item.shortDescriptionOnLine2 ?? false} /> ) From 7668c3e8f22eb7a0f384621daf2f4eabbbaa1dc4 Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Tue, 30 Dec 2025 18:32:40 -0800 Subject: [PATCH 05/31] Fix: Remove incorrect response.success check in handleRemoveFavorite The requestFromPlugin function resolves with just the data portion, not a wrapper object. Since the promise only resolves on success, we don't need to check for success. This fixes the false 'Unknown error' message when removing favorites. --- .../src/components/FavoritesView.jsx | 27 +++++++------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/dwertheimer.Favorites/src/components/FavoritesView.jsx b/dwertheimer.Favorites/src/components/FavoritesView.jsx index e11b43d32..743eb8455 100644 --- a/dwertheimer.Favorites/src/components/FavoritesView.jsx +++ b/dwertheimer.Favorites/src/components/FavoritesView.jsx @@ -426,24 +426,15 @@ function FavoritesViewComponent({ // Handle removing favorite note const handleRemoveFavorite = useCallback(async (filename: string) => { try { - const response = await requestFromPlugin('removeFavoriteNote', { filename }) - if (response && response.success) { - // Show toast notification - dispatch('SHOW_TOAST', { - type: 'SUCCESS', - msg: 'Favorite note removed', - timeout: 2000, - }) - // Reload the favorites list - await loadFavoriteNotes() - } else { - logError('FavoritesView', `Failed to remove favorite note: ${response?.message || 'Unknown error'}`) - dispatch('SHOW_TOAST', { - type: 'ERROR', - msg: `Failed to remove favorite: ${response?.message || 'Unknown error'}`, - timeout: 3000, - }) - } + await requestFromPlugin('removeFavoriteNote', { filename }) + // Show toast notification + dispatch('SHOW_TOAST', { + type: 'SUCCESS', + msg: 'Favorite note removed', + timeout: 2000, + }) + // Reload the favorites list + await loadFavoriteNotes() } catch (error) { logError('FavoritesView', `Error removing favorite note: ${error.message}`) dispatch('SHOW_TOAST', { From 9ce9538fd3c913d295e08d5a7eabaf2de80433f6 Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Tue, 30 Dec 2025 18:38:16 -0800 Subject: [PATCH 06/31] Add IdleTimer to Favorites: reset to notes view, clear filter, scroll to top, and focus filter after 1min idle - Copy IdleTimer component to helpers/react - Add IDLE_TIMEOUT_MS constant (60000ms = 1 minute) - Add filterInputRef and update FilterableList to accept it - Update handleIdleTimeout to reset showNotes, clear filter, scroll to top, and focus filter - Add showTitleOnly prop to NoteChooser to show only note title (not path/title) - Update note-chooser in add favorite dialog to use 2-line layout (shortDescriptionOnLine2) and showTitleOnly --- .../src/components/FavoritesView.jsx | 759 ++++++++++-------- dwertheimer.Forms/plugin.json | 2 +- .../src/components/FormSettings.jsx | 32 +- helpers/react/DynamicDialog/NoteChooser.jsx | 6 + .../react/DynamicDialog/SearchableChooser.css | 46 +- .../DynamicDialog/dialogElementRenderer.js | 1 + helpers/react/FilterableList.jsx | 3 + helpers/react/IdleTimer.jsx | 94 +++ 8 files changed, 576 insertions(+), 367 deletions(-) create mode 100644 helpers/react/IdleTimer.jsx diff --git a/dwertheimer.Favorites/src/components/FavoritesView.jsx b/dwertheimer.Favorites/src/components/FavoritesView.jsx index 743eb8455..e16acef7b 100644 --- a/dwertheimer.Favorites/src/components/FavoritesView.jsx +++ b/dwertheimer.Favorites/src/components/FavoritesView.jsx @@ -15,8 +15,12 @@ import { type TSettingItem } from '@helpers/react/DynamicDialog/DynamicDialog' import { type NoteOption } from '@helpers/react/DynamicDialog/NoteChooser' import { waitForCondition } from '@helpers/promisePolyfill' import { InfoIcon } from '@helpers/react/InfoIcon' +import IdleTimer from '@helpers/react/IdleTimer' import './FavoritesView.css' +// Idle timeout: reset to notes view and focus filter after 1 minute of inactivity +const IDLE_TIMEOUT_MS = 60000 // 1 minute + type FavoriteNote = { filename: string, title: string, @@ -82,44 +86,48 @@ function FavoritesViewComponent({ const [addCommandDialogData, setAddCommandDialogData] = useState<{ [key: string]: any }>({}) const [newlyAddedFilename, setNewlyAddedFilename] = useState(null) // Track newly added item for highlighting const listRef = useRef(null) // Ref for scrolling to items + const filterInputRef = useRef(null) // Ref for the filter input field // Request function - const requestFromPlugin = useCallback((command: string, dataToSend: any = {}, timeout: number = 10000): Promise => { - if (!command) throw new Error('requestFromPlugin: command must be called with a string') - - const correlationId = `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` - logDebug('FavoritesView', `requestFromPlugin: command="${command}", correlationId="${correlationId}"`) - - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - const pending = pendingRequestsRef.current.get(correlationId) - if (pending) { - pendingRequestsRef.current.delete(correlationId) - logDebug('FavoritesView', `requestFromPlugin TIMEOUT: command="${command}", correlationId="${correlationId}"`) - reject(new Error(`Request timeout: ${command}`)) - } - }, timeout) + const requestFromPlugin = useCallback( + (command: string, dataToSend: any = {}, timeout: number = 10000): Promise => { + if (!command) throw new Error('requestFromPlugin: command must be called with a string') + + const correlationId = `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + logDebug('FavoritesView', `requestFromPlugin: command="${command}", correlationId="${correlationId}"`) + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + const pending = pendingRequestsRef.current.get(correlationId) + if (pending) { + pendingRequestsRef.current.delete(correlationId) + logDebug('FavoritesView', `requestFromPlugin TIMEOUT: command="${command}", correlationId="${correlationId}"`) + reject(new Error(`Request timeout: ${command}`)) + } + }, timeout) - pendingRequestsRef.current.set(correlationId, { resolve, reject, timeoutId }) + pendingRequestsRef.current.set(correlationId, { resolve, reject, timeoutId }) - const requestData = { - ...dataToSend, - __correlationId: correlationId, - __requestType: 'REQUEST', - __windowId: windowIdRef.current || '', - } + const requestData = { + ...dataToSend, + __correlationId: correlationId, + __requestType: 'REQUEST', + __windowId: windowIdRef.current || '', + } - dispatch('SEND_TO_PLUGIN', [command, requestData], `FavoritesView: requestFromPlugin: ${String(command)}`) - }) - .then((result) => { - logDebug('FavoritesView', `requestFromPlugin RESOLVED: command="${command}", correlationId="${correlationId}"`) - return result + dispatch('SEND_TO_PLUGIN', [command, requestData], `FavoritesView: requestFromPlugin: ${String(command)}`) }) - .catch((error) => { - logError('FavoritesView', `requestFromPlugin REJECTED: command="${command}", correlationId="${correlationId}", error="${error.message}"`) - throw error - }) - }, [dispatch]) + .then((result) => { + logDebug('FavoritesView', `requestFromPlugin RESOLVED: command="${command}", correlationId="${correlationId}"`) + return result + }) + .catch((error) => { + logError('FavoritesView', `requestFromPlugin REJECTED: command="${command}", correlationId="${correlationId}", error="${error.message}"`) + throw error + }) + }, + [dispatch], + ) // Listen for RESPONSE messages useEffect(() => { @@ -246,70 +254,73 @@ function FavoritesViewComponent({ }, [requestFromPlugin]) // Handle adding favorite note dialog - const handleAddNoteDialogSave = useCallback((updatedSettings: { [key: string]: any }) => { - ;(async () => { - try { - if (updatedSettings.note) { - const filename = updatedSettings.note - - // Close dialog immediately - setShowAddNoteDialog(false) - setAddNoteDialogData({}) - - // Add the favorite - // Note: requestFromPlugin resolves with result.data (unwrapped), or rejects on error - // If we get here without throwing, the request succeeded - const response = await requestFromPlugin('addFavoriteNote', { filename }) - logDebug('FavoritesView', `addFavoriteNote response:`, response) - - // Show success toast + const handleAddNoteDialogSave = useCallback( + (updatedSettings: { [key: string]: any }) => { + await(async () => { + try { + if (updatedSettings.note) { + const filename = updatedSettings.note + + // Close dialog immediately + setShowAddNoteDialog(false) + setAddNoteDialogData({}) + + // Add the favorite + // Note: requestFromPlugin resolves with result.data (unwrapped), or rejects on error + // If we get here without throwing, the request succeeded + const response = await requestFromPlugin('addFavoriteNote', { filename }) + logDebug('FavoritesView', `addFavoriteNote response:`, response) + + // Show success toast + dispatch('SHOW_TOAST', { + type: 'SUCCESS', + msg: 'Favorite note added successfully', + timeout: 3000, + }) + + // Reload the favorites list first + await loadFavoriteNotes() + + // Wait for the note to appear in the list by checking the actual list data + // We need to reload and check, since state updates are async + const found = await waitForCondition( + async () => { + // Reload notes to get fresh data, then check + if (showNotes) { + const notes = await requestFromPlugin('getFavoriteNotes') + if (Array.isArray(notes)) { + return notes.some((note) => note.filename === filename) + } + } + return false + }, + { maxWaitMs: 3000, checkIntervalMs: 150 }, + ) + + // Reload one more time to ensure UI is in sync + await loadFavoriteNotes() + + // Set the newly added filename for highlighting (useEffect will handle scrolling) + setNewlyAddedFilename(filename) + + if (found) { + logDebug('FavoritesView', 'Successfully added favorite note and found it in list') + } else { + logError('FavoritesView', 'Added favorite note but could not find it in list after waiting') + } + } + } catch (error) { + logError('FavoritesView', `Error adding favorite note: ${error.message}`) dispatch('SHOW_TOAST', { - type: 'SUCCESS', - msg: 'Favorite note added successfully', + type: 'ERROR', + msg: `Error adding favorite: ${error.message}`, timeout: 3000, }) - - // Reload the favorites list first - await loadFavoriteNotes() - - // Wait for the note to appear in the list by checking the actual list data - // We need to reload and check, since state updates are async - const found = await waitForCondition( - async () => { - // Reload notes to get fresh data, then check - if (showNotes) { - const notes = await requestFromPlugin('getFavoriteNotes') - if (Array.isArray(notes)) { - return notes.some((note) => note.filename === filename) - } - } - return false - }, - { maxWaitMs: 3000, checkIntervalMs: 150 } - ) - - // Reload one more time to ensure UI is in sync - await loadFavoriteNotes() - - // Set the newly added filename for highlighting (useEffect will handle scrolling) - setNewlyAddedFilename(filename) - - if (found) { - logDebug('FavoritesView', 'Successfully added favorite note and found it in list') - } else { - logError('FavoritesView', 'Added favorite note but could not find it in list after waiting') - } } - } catch (error) { - logError('FavoritesView', `Error adding favorite note: ${error.message}`) - dispatch('SHOW_TOAST', { - type: 'ERROR', - msg: `Error adding favorite: ${error.message}`, - timeout: 3000, - }) - } - })() - }, [requestFromPlugin, loadFavoriteNotes, dispatch, showNotes, favoriteNotes]) + })() + }, + [requestFromPlugin, loadFavoriteNotes, dispatch, showNotes, favoriteNotes], + ) const handleAddNoteDialogCancel = useCallback(() => { setShowAddNoteDialog(false) @@ -325,52 +336,58 @@ function FavoritesViewComponent({ }, [projectNotes, loadProjectNotes]) // Handle adding favorite command dialog - const handleAddCommandDialogSave = useCallback((updatedSettings: { [key: string]: any }) => { - ;(async () => { - try { - if (updatedSettings.preset && updatedSettings.commandName && updatedSettings.url) { - const response = await requestFromPlugin('addFavoriteCommand', { - jsFunction: updatedSettings.preset, - name: updatedSettings.commandName, - data: updatedSettings.url, - }) - if (response && response.success) { - await loadFavoriteCommands() - setShowAddCommandDialog(false) - setAddCommandDialogData({}) - logDebug('FavoritesView', 'Successfully added favorite command') - } else { - logError('FavoritesView', `Failed to add favorite command: ${response?.message || 'Unknown error'}`) + const handleAddCommandDialogSave = useCallback( + (updatedSettings: { [key: string]: any }) => { + await(async () => { + try { + if (updatedSettings.preset && updatedSettings.commandName && updatedSettings.url) { + const response = await requestFromPlugin('addFavoriteCommand', { + jsFunction: updatedSettings.preset, + name: updatedSettings.commandName, + data: updatedSettings.url, + }) + if (response && response.success) { + await loadFavoriteCommands() + setShowAddCommandDialog(false) + setAddCommandDialogData({}) + logDebug('FavoritesView', 'Successfully added favorite command') + } else { + logError('FavoritesView', `Failed to add favorite command: ${response?.message || 'Unknown error'}`) + } } + } catch (error) { + logError('FavoritesView', `Error adding favorite command: ${error.message}`) } - } catch (error) { - logError('FavoritesView', `Error adding favorite command: ${error.message}`) - } - })() - }, [requestFromPlugin, loadFavoriteCommands]) + })() + }, + [requestFromPlugin, loadFavoriteCommands], + ) const handleAddCommandDialogCancel = useCallback(() => { setShowAddCommandDialog(false) setAddCommandDialogData({}) }, []) - const handleAddCommandButtonClick = useCallback((key: string, value: string) => { - if (key === 'getCallbackURL') { - ;(async () => { - try { - const urlResponse = await requestFromPlugin('getCallbackURL', {}) - if (urlResponse && urlResponse.success && urlResponse.url) { - // Update the URL field in the dialog - setAddCommandDialogData((prev) => ({ ...prev, url: urlResponse.url })) - logDebug('FavoritesView', `Got URL from Link Creator: ${urlResponse.url}`) + const handleAddCommandButtonClick = useCallback( + (key: string, value: string) => { + if (key === 'getCallbackURL') { + await(async () => { + try { + const urlResponse = await requestFromPlugin('getCallbackURL', {}) + if (urlResponse && urlResponse.success && urlResponse.url) { + // Update the URL field in the dialog + setAddCommandDialogData((prev) => ({ ...prev, url: urlResponse.url })) + logDebug('FavoritesView', `Got URL from Link Creator: ${urlResponse.url}`) + } + } catch (error) { + logError('FavoritesView', `Error getting callback URL: ${error.message}`) } - } catch (error) { - logError('FavoritesView', `Error getting callback URL: ${error.message}`) - } - })() - return false // Don't close dialog - } - }, [requestFromPlugin]) + })() + return false // Don't close dialog + } + }, + [requestFromPlugin], + ) const handleAddFavoriteCommand = useCallback(async () => { // Load preset commands if not already loaded @@ -388,35 +405,46 @@ function FavoritesViewComponent({ // Handle item click // Note: __windowId is automatically injected by Root.jsx sendToPlugin, so we don't need to add it here - const handleItemClick = useCallback((item: FavoriteNote | FavoriteCommand, event: MouseEvent) => { - const isOptionClick = event.altKey || event.metaKey === false && event.ctrlKey // Alt key (option on Mac) - const isCmdClick = event.metaKey || event.ctrlKey // Cmd key (meta on Mac, ctrl on Windows) - - if (showNotes) { - // $FlowFixMe[incompatible-cast] - item is FavoriteNote when showNotes is true - const note: FavoriteNote = (item: any) - // Send action to plugin to open note - dispatch('SEND_TO_PLUGIN', [ - 'openNote', - { - filename: note.filename, - newWindow: isCmdClick, // Cmd-click opens in floating window - splitView: isOptionClick, // Option-click opens in split view - }, - ], 'FavoritesView: openNote') - } else { - // $FlowFixMe[incompatible-cast] - item is FavoriteCommand when showNotes is false - const command: FavoriteCommand = (item: any) - // Send action to plugin to run command - dispatch('SEND_TO_PLUGIN', [ - 'runCommand', - { - jsFunction: command.jsFunction, - data: command.data, - }, - ], 'FavoritesView: runCommand') - } - }, [showNotes, dispatch]) + const handleItemClick = useCallback( + (item: FavoriteNote | FavoriteCommand, event: MouseEvent) => { + const isOptionClick = event.altKey || (event.metaKey === false && event.ctrlKey) // Alt key (option on Mac) + const isCmdClick = event.metaKey || event.ctrlKey // Cmd key (meta on Mac, ctrl on Windows) + + if (showNotes) { + // $FlowFixMe[incompatible-cast] - item is FavoriteNote when showNotes is true + const note: FavoriteNote = (item: any) + // Send action to plugin to open note + dispatch( + 'SEND_TO_PLUGIN', + [ + 'openNote', + { + filename: note.filename, + newWindow: isCmdClick, // Cmd-click opens in floating window + splitView: isOptionClick, // Option-click opens in split view + }, + ], + 'FavoritesView: openNote', + ) + } else { + // $FlowFixMe[incompatible-cast] - item is FavoriteCommand when showNotes is false + const command: FavoriteCommand = (item: any) + // Send action to plugin to run command + dispatch( + 'SEND_TO_PLUGIN', + [ + 'runCommand', + { + jsFunction: command.jsFunction, + data: command.data, + }, + ], + 'FavoritesView: runCommand', + ) + } + }, + [showNotes, dispatch], + ) // Get current items based on view type const currentItems = useMemo(() => { @@ -424,67 +452,98 @@ function FavoritesViewComponent({ }, [showNotes, favoriteNotes, favoriteCommands]) // Handle removing favorite note - const handleRemoveFavorite = useCallback(async (filename: string) => { - try { - await requestFromPlugin('removeFavoriteNote', { filename }) - // Show toast notification - dispatch('SHOW_TOAST', { - type: 'SUCCESS', - msg: 'Favorite note removed', - timeout: 2000, - }) - // Reload the favorites list - await loadFavoriteNotes() - } catch (error) { - logError('FavoritesView', `Error removing favorite note: ${error.message}`) - dispatch('SHOW_TOAST', { - type: 'ERROR', - msg: `Error removing favorite: ${error.message}`, - timeout: 3000, - }) - } - }, [requestFromPlugin, loadFavoriteNotes, dispatch]) + const handleRemoveFavorite = useCallback( + async (filename: string) => { + try { + await requestFromPlugin('removeFavoriteNote', { filename }) + // Show toast notification + dispatch('SHOW_TOAST', { + type: 'SUCCESS', + msg: 'Favorite note removed', + timeout: 2000, + }) + // Reload the favorites list + await loadFavoriteNotes() + } catch (error) { + logError('FavoritesView', `Error removing favorite note: ${error.message}`) + dispatch('SHOW_TOAST', { + type: 'ERROR', + msg: `Error removing favorite: ${error.message}`, + timeout: 3000, + }) + } + }, + [requestFromPlugin, loadFavoriteNotes, dispatch], + ) - // Render note item - const renderNoteItem = useCallback((item: any, index: number): Node => { - // $FlowFixMe[incompatible-cast] - item is FavoriteNote when showNotes is true - const note: FavoriteNote = item - const folder = note.folder || '' - const folderDisplay = folder && folder !== '/' ? `${folder} / ` : '' - const displayTitle = note.title || note.filename || 'Untitled' - - // Always show an icon - use note icon if provided, otherwise use default - const icon = note.icon || defaultNoteIconDetails.icon - const color = note.color || defaultNoteIconDetails.color - const isNewlyAdded = newlyAddedFilename === note.filename + // Handle idle timeout: reset to notes view and focus filter + const handleIdleTimeout = useCallback(() => { + setShowNotes(true) + setFilterText('') + setSelectedIndex(null) + // Scroll list to top and focus the filter input after a brief delay to ensure it's rendered + setTimeout(() => { + // Scroll list to top + if (listRef.current) { + const firstItem = listRef.current.querySelector('[data-index="0"]') + if (firstItem instanceof HTMLElement) { + firstItem.scrollIntoView({ block: 'start', behavior: 'instant' }) + } else if (listRef.current instanceof HTMLElement) { + // If no items, try scrolling the container itself + const scrollableParent = listRef.current.parentElement?.parentElement + if (scrollableParent instanceof HTMLElement && scrollableParent.scrollTop !== undefined) { + scrollableParent.scrollTop = 0 + } + } + } + // Focus the filter input + if (filterInputRef.current) { + filterInputRef.current.focus() + } + }, 0) + }, []) - return ( -
- -
-
{displayTitle}
- {folder && folder !== '/' && ( -
{folderDisplay}
- )} + // Render note item + const renderNoteItem = useCallback( + (item: any, index: number): Node => { + // $FlowFixMe[incompatible-cast] - item is FavoriteNote when showNotes is true + const note: FavoriteNote = item + const folder = note.folder || '' + const folderDisplay = folder && folder !== '/' ? `${folder} / ` : '' + const displayTitle = note.title || note.filename || 'Untitled' + + // Always show an icon - use note icon if provided, otherwise use default + const icon = note.icon || defaultNoteIconDetails.icon + const color = note.color || defaultNoteIconDetails.color + const isNewlyAdded = newlyAddedFilename === note.filename + + return ( +
+ +
+
{displayTitle}
+ {folder && folder !== '/' &&
{folderDisplay}
} +
+ { + e.preventDefault() + e.stopPropagation() + handleRemoveFavorite(note.filename) + }} + />
- { - e.preventDefault() - e.stopPropagation() - handleRemoveFavorite(note.filename) - }} - /> -
- ) - }, [newlyAddedFilename, handleRemoveFavorite]) + ) + }, + [newlyAddedFilename, handleRemoveFavorite], + ) // Render command item const renderCommandItem = useCallback((item: any, index: number): Node => { @@ -495,9 +554,7 @@ function FavoritesViewComponent({
{command.name}
- {command.description && ( -
{command.description}
- )} + {command.description &&
{command.description}
}
) @@ -526,104 +583,117 @@ function FavoritesViewComponent({ }, []) // Get item label for filtering - const getItemLabel = useCallback((item: any): string => { - if (showNotes) { - // $FlowFixMe[incompatible-cast] - item is FavoriteNote when showNotes is true - const note: FavoriteNote = item - return note.title || note.filename || '' - } else { - // $FlowFixMe[incompatible-cast] - item is FavoriteCommand when showNotes is false - const command: FavoriteCommand = item - return command.name || '' - } - }, [showNotes]) + const getItemLabel = useCallback( + (item: any): string => { + if (showNotes) { + // $FlowFixMe[incompatible-cast] - item is FavoriteNote when showNotes is true + const note: FavoriteNote = item + return note.title || note.filename || '' + } else { + // $FlowFixMe[incompatible-cast] - item is FavoriteCommand when showNotes is false + const command: FavoriteCommand = item + return command.name || '' + } + }, + [showNotes], + ) // Handle toggle change - const handleToggleChange = useCallback((newShowNotes: boolean) => { - setShowNotes(newShowNotes) - setReactSettings((prev: any) => ({ ...prev, showNotes: newShowNotes })) - setFilterText('') // Clear filter when switching - setSelectedIndex(null) // Reset selection - }, [setReactSettings]) + const handleToggleChange = useCallback( + (newShowNotes: boolean) => { + setShowNotes(newShowNotes) + setReactSettings((prev: any) => ({ ...prev, showNotes: newShowNotes })) + setFilterText('') // Clear filter when switching + setSelectedIndex(null) // Reset selection + }, + [setReactSettings], + ) // Handle keyboard navigation // Arrow keys only navigate (change selectedIndex) - they do NOT trigger actions // Click and Enter trigger actions (run command or open note) - const handleKeyDown = useCallback((event: KeyboardEvent) => { - if (event.key === 'ArrowDown') { - event.preventDefault() - // Arrow navigation only - no action triggered - const newIndex = selectedIndex === null || selectedIndex === undefined ? 0 : selectedIndex + 1 - if (newIndex < currentItems.length) { - setSelectedIndex(newIndex) - // Scroll into view + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'ArrowDown') { + event.preventDefault() + // Arrow navigation only - no action triggered + const newIndex = selectedIndex === null || selectedIndex === undefined ? 0 : selectedIndex + 1 + if (newIndex < currentItems.length) { + setSelectedIndex(newIndex) + // Scroll into view + setTimeout(() => { + if (listRef.current) { + const item = listRef.current.querySelector(`[data-index="${newIndex}"]`) + if (item instanceof HTMLElement) { + item.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + item.focus() + } + } + }, 0) + } + } else if (event.key === 'ArrowUp') { + event.preventDefault() + // Arrow navigation only - no action triggered + if (selectedIndex !== null && selectedIndex !== undefined && selectedIndex > 0) { + const newIndex = selectedIndex - 1 + setSelectedIndex(newIndex) + // Scroll into view + setTimeout(() => { + if (listRef.current) { + const item = listRef.current.querySelector(`[data-index="${newIndex}"]`) + if (item instanceof HTMLElement) { + item.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + item.focus() + } + } + }, 0) + } + } else if (event.key === 'Enter' && selectedIndex !== null && selectedIndex !== undefined && selectedIndex >= 0 && selectedIndex < currentItems.length) { + event.preventDefault() + // Enter key triggers the action (run command via x-callback URL or open note) + const item = currentItems[selectedIndex] + if (item) { + handleItemClick(item, (event: any)) + } + } + }, + [currentItems, selectedIndex, handleItemClick], + ) + + // Handle filter input keydown + const handleFilterKeyDown = useCallback( + (e: any) => { + // SyntheticKeyboardEvent + if (e.key === 'ArrowDown' && currentItems.length > 0) { + e.preventDefault() + setSelectedIndex(0) + // Focus the list with setTimeout to ensure DOM is updated setTimeout(() => { if (listRef.current) { - const item = listRef.current.querySelector(`[data-index="${newIndex}"]`) - if (item instanceof HTMLElement) { - item.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) - item.focus() + const firstItem = listRef.current.querySelector('[data-index="0"]') + if (firstItem instanceof HTMLElement) { + firstItem.focus() } } }, 0) - } - } else if (event.key === 'ArrowUp') { - event.preventDefault() - // Arrow navigation only - no action triggered - if (selectedIndex !== null && selectedIndex !== undefined && selectedIndex > 0) { - const newIndex = selectedIndex - 1 - setSelectedIndex(newIndex) - // Scroll into view + } else if (e.key === 'Tab' && !e.shiftKey && currentItems.length > 0) { + e.preventDefault() + setSelectedIndex(0) setTimeout(() => { if (listRef.current) { - const item = listRef.current.querySelector(`[data-index="${newIndex}"]`) - if (item instanceof HTMLElement) { - item.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) - item.focus() + const firstItem = listRef.current.querySelector('[data-index="0"]') + if (firstItem instanceof HTMLElement) { + firstItem.focus() } } }, 0) + } else { + // Pass other keys to handleKeyDown + handleKeyDown(e.nativeEvent) } - } else if (event.key === 'Enter' && selectedIndex !== null && selectedIndex !== undefined && selectedIndex >= 0 && selectedIndex < currentItems.length) { - event.preventDefault() - // Enter key triggers the action (run command via x-callback URL or open note) - const item = currentItems[selectedIndex] - if (item) { - handleItemClick(item, (event: any)) - } - } - }, [currentItems, selectedIndex, handleItemClick]) - - // Handle filter input keydown - const handleFilterKeyDown = useCallback((e: any) => { // SyntheticKeyboardEvent - if (e.key === 'ArrowDown' && currentItems.length > 0) { - e.preventDefault() - setSelectedIndex(0) - // Focus the list with setTimeout to ensure DOM is updated - setTimeout(() => { - if (listRef.current) { - const firstItem = listRef.current.querySelector('[data-index="0"]') - if (firstItem instanceof HTMLElement) { - firstItem.focus() - } - } - }, 0) - } else if (e.key === 'Tab' && !e.shiftKey && currentItems.length > 0) { - e.preventDefault() - setSelectedIndex(0) - setTimeout(() => { - if (listRef.current) { - const firstItem = listRef.current.querySelector('[data-index="0"]') - if (firstItem instanceof HTMLElement) { - firstItem.focus() - } - } - }, 0) - } else { - // Pass other keys to handleKeyDown - handleKeyDown(e.nativeEvent) - } - }, [currentItems.length, handleKeyDown]) + }, + [currentItems.length, handleKeyDown], + ) return (
@@ -672,6 +742,7 @@ function FavoritesViewComponent({
+ @@ -707,6 +779,8 @@ function FavoritesViewComponent({ includeRelativeNotes: false, includeTeamspaceNotes: true, required: true, + shortDescriptionOnLine2: true, + showTitleOnly: true, }, { type: 'markdown-preview', @@ -776,13 +850,7 @@ function FavoritesViewComponent({ /** * Root FavoritesView Component with AppProvider */ -export function FavoritesView({ - data, - dispatch, - reactSettings, - setReactSettings, - onSubmitOrCancelCallFunctionNamed, -}: FavoritesViewProps): Node { +export function FavoritesView({ data, dispatch, reactSettings, setReactSettings, onSubmitOrCancelCallFunctionNamed }: FavoritesViewProps): Node { // Map to store pending requests const pendingRequestsRef = useRef void, reject: (error: Error) => void, timeoutId: any }>>(new Map()) @@ -794,32 +862,35 @@ export function FavoritesView({ }, [pluginData?.windowId]) // Request function for AppContext - const requestFromPlugin = useCallback((command: string, dataToSend: any = {}, timeout: number = 10000): Promise => { - if (!command) throw new Error('requestFromPlugin: command must be called with a string') + const requestFromPlugin = useCallback( + (command: string, dataToSend: any = {}, timeout: number = 10000): Promise => { + if (!command) throw new Error('requestFromPlugin: command must be called with a string') + + const correlationId = `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + const pending = pendingRequestsRef.current.get(correlationId) + if (pending) { + pendingRequestsRef.current.delete(correlationId) + reject(new Error(`Request timeout: ${command}`)) + } + }, timeout) - const correlationId = `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + pendingRequestsRef.current.set(correlationId, { resolve, reject, timeoutId }) - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - const pending = pendingRequestsRef.current.get(correlationId) - if (pending) { - pendingRequestsRef.current.delete(correlationId) - reject(new Error(`Request timeout: ${command}`)) + const requestData = { + ...dataToSend, + __correlationId: correlationId, + __requestType: 'REQUEST', + __windowId: windowIdRef.current || '', } - }, timeout) - - pendingRequestsRef.current.set(correlationId, { resolve, reject, timeoutId }) - const requestData = { - ...dataToSend, - __correlationId: correlationId, - __requestType: 'REQUEST', - __windowId: windowIdRef.current || '', - } - - dispatch('SEND_TO_PLUGIN', [command, requestData], `FavoritesView: requestFromPlugin: ${String(command)}`) - }) - }, [dispatch]) + dispatch('SEND_TO_PLUGIN', [command, requestData], `FavoritesView: requestFromPlugin: ${String(command)}`) + }) + }, + [dispatch], + ) // Listen for RESPONSE messages useEffect(() => { @@ -857,18 +928,27 @@ export function FavoritesView({ } }, []) - const sendActionToPlugin = useCallback((command: string, dataToSend: any) => { - dispatch('SEND_TO_PLUGIN', [command, dataToSend], `FavoritesView: sendActionToPlugin: ${String(command)}`) - }, [dispatch]) + const sendActionToPlugin = useCallback( + (command: string, dataToSend: any) => { + dispatch('SEND_TO_PLUGIN', [command, dataToSend], `FavoritesView: sendActionToPlugin: ${String(command)}`) + }, + [dispatch], + ) - const sendToPlugin = useCallback((command: string, dataToSend: any) => { - dispatch('SEND_TO_PLUGIN', [command, dataToSend], `FavoritesView: sendToPlugin: ${String(command)}`) - }, [dispatch]) + const sendToPlugin = useCallback( + (command: string, dataToSend: any) => { + dispatch('SEND_TO_PLUGIN', [command, dataToSend], `FavoritesView: sendToPlugin: ${String(command)}`) + }, + [dispatch], + ) - const updatePluginData = useCallback((newData: any, messageForLog?: string) => { - const newFullData = { ...data, pluginData: newData } - dispatch('UPDATE_DATA', newFullData, messageForLog) - }, [data, dispatch]) + const updatePluginData = useCallback( + (newData: any, messageForLog?: string) => { + const newFullData = { ...data, pluginData: newData } + dispatch('UPDATE_DATA', newFullData, messageForLog) + }, + [data, dispatch], + ) return ( ) } - diff --git a/dwertheimer.Forms/plugin.json b/dwertheimer.Forms/plugin.json index b411ba50c..c0489b55a 100644 --- a/dwertheimer.Forms/plugin.json +++ b/dwertheimer.Forms/plugin.json @@ -51,7 +51,7 @@ ] }, { - "name": "Form Browser", + "name": "Form Browser - open in NotePlan Editor", "alias": [ "browser", "formbrowser" diff --git a/dwertheimer.Forms/src/components/FormSettings.jsx b/dwertheimer.Forms/src/components/FormSettings.jsx index 748d11a36..0ada07506 100644 --- a/dwertheimer.Forms/src/components/FormSettings.jsx +++ b/dwertheimer.Forms/src/components/FormSettings.jsx @@ -5,10 +5,10 @@ import React, { useState, type Node } from 'react' import { ProcessingMethodSection } from './ProcessingMethodSection.jsx' +import { PositionInput } from './PositionInput.jsx' import { InfoIcon } from '@helpers/react/InfoIcon.jsx' import { type TSettingItem } from '@helpers/react/DynamicDialog/DynamicDialog.jsx' import { type NoteOption } from '@helpers/react/DynamicDialog/NoteChooser.jsx' -import { PositionInput } from './PositionInput.jsx' type FormSettingsProps = { frontmatter: { [key: string]: any }, @@ -159,7 +159,9 @@ export function FormSettings({
- +
- +
- +
- onFrontmatterChange('x', value)} - placeholder="center, left, right, or 25%" - /> + onFrontmatterChange('x', value)} placeholder="center, left, right, or 25%" />
- +
- onFrontmatterChange('y', value)} - placeholder="center, top, bottom, or 25%" - /> + onFrontmatterChange('y', value)} placeholder="center, top, bottom, or 25%" />
diff --git a/helpers/react/DynamicDialog/NoteChooser.jsx b/helpers/react/DynamicDialog/NoteChooser.jsx index 5f9d41111..f76882be8 100644 --- a/helpers/react/DynamicDialog/NoteChooser.jsx +++ b/helpers/react/DynamicDialog/NoteChooser.jsx @@ -54,6 +54,7 @@ export type NoteChooserProps = { onOpen?: () => void, // Callback when dropdown opens (for lazy loading) - can be async internally isLoading?: boolean, // If true, show loading indicator shortDescriptionOnLine2?: boolean, // If true, render short description on second line (default: false) + showTitleOnly?: boolean, // If true, show only the note title in the label (not "path / title") (default: false) } /** @@ -138,6 +139,7 @@ export function NoteChooser({ onOpen, isLoading = false, shortDescriptionOnLine2 = false, + showTitleOnly = false, }: NoteChooserProps): React$Node { const [isCreatingNote, setIsCreatingNote] = useState(false) const [showCreateDialog, setShowCreateDialog] = useState(false) @@ -350,6 +352,10 @@ export function NoteChooser({ if (note.filename === '__NEW_NOTE__') { return '➕ New Note' } + // If showTitleOnly is true, always return just the title + if (showTitleOnly) { + return note.title + } // For personal/project notes, show "path / title" format to match native chooser // For calendar notes, show just the title if (note.type === 'Notes' || !note.type) { diff --git a/helpers/react/DynamicDialog/SearchableChooser.css b/helpers/react/DynamicDialog/SearchableChooser.css index 928269b7f..ce7e763de 100644 --- a/helpers/react/DynamicDialog/SearchableChooser.css +++ b/helpers/react/DynamicDialog/SearchableChooser.css @@ -219,12 +219,12 @@ gap: 0.5rem; } -/* Also support attribute selector for backward compatibility */ -.searchable-chooser-base [class*="-chooser-option"], -[class*="-chooser-option"] { +/* Also support attribute selector for backward compatibility - but exclude two-line options and inner lines */ +.searchable-chooser-base [class*="-chooser-option"]:not([class*="-chooser-option-two-line"]):not([class*="-chooser-option-first-line"]):not([class*="-chooser-option-second-line"]), +[class*="-chooser-option"]:not([class*="-chooser-option-two-line"]):not([class*="-chooser-option-first-line"]):not([class*="-chooser-option-second-line"]) { padding: 0.25rem 0.75rem; cursor: pointer; - border-bottom: 1px solid var(--border-color, #f0f0f0); + /* border-bottom: 1px solid var(--border-color, #f0f0f0); */ color: var(--text-color, #333); font-size: 0.9rem; white-space: nowrap; @@ -233,7 +233,7 @@ display: flex; align-items: center; justify-content: space-between; - gap: 0.5rem; + /* gap: 0.5rem; */ } /* Left side of option (icon + text) */ @@ -283,9 +283,15 @@ /* Two-line layout for options */ .searchable-chooser-option-two-line { + padding: 0.5rem 0.75rem; + cursor: pointer; + border-bottom: 1px solid var(--border-color, #f0f0f0); + color: var(--text-color, #333); + font-size: 0.9rem; flex-direction: column; align-items: flex-start; white-space: normal; + display: flex; } .searchable-chooser-option-first-line { @@ -295,26 +301,40 @@ width: 100%; min-width: 0; overflow: hidden; + border-bottom: none !important; + margin-bottom: 0; + padding-bottom: 0; } .searchable-chooser-option-second-line { font-size: 0.85em; opacity: 0.6; color: var(--gray-500, #666); - margin-top: 0.25rem; + margin-top: 0; + padding-top: 0; padding-left: 0; + padding-right: 0; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + text-align: right; + border-bottom: none !important; + line-height: 1.2; } -/* Support attribute selector for two-line layout */ +/* Support attribute selector for two-line layout - ensure it overrides base option styles */ .searchable-chooser-base [class*="-chooser-option-two-line"], [class*="-chooser-option-two-line"] { + padding: 0.5rem 0.75rem !important; + cursor: pointer; + border-bottom: 1px solid var(--border-color, #f0f0f0); + color: var(--text-color, #333); + font-size: 0.9rem; flex-direction: column; align-items: flex-start; - white-space: normal; + white-space: normal !important; + display: flex; } .searchable-chooser-base [class*="-chooser-option-first-line"], @@ -325,6 +345,9 @@ width: 100%; min-width: 0; overflow: hidden; + border-bottom: none !important; + margin-bottom: 0 !important; + padding-bottom: 0 !important; } .searchable-chooser-base [class*="-chooser-option-second-line"], @@ -332,12 +355,17 @@ font-size: 0.85em; opacity: 0.6; color: var(--gray-500, #666); - margin-top: 0.25rem; + margin-top: 0 !important; + padding-top: 0 !important; padding-left: 0; + padding-right: 0; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + text-align: right; + border-bottom: none !important; + line-height: 1.2; } .searchable-chooser-base [class*="-chooser-option-right"], diff --git a/helpers/react/DynamicDialog/dialogElementRenderer.js b/helpers/react/DynamicDialog/dialogElementRenderer.js index 3649c67de..8c989b571 100644 --- a/helpers/react/DynamicDialog/dialogElementRenderer.js +++ b/helpers/react/DynamicDialog/dialogElementRenderer.js @@ -532,6 +532,7 @@ export function renderItem({ placeholder={item.placeholder || 'Type to search notes...'} showValue={item.showValue ?? false} shortDescriptionOnLine2={item.shortDescriptionOnLine2 ?? false} + showTitleOnly={item.showTitleOnly ?? false} />
) diff --git a/helpers/react/FilterableList.jsx b/helpers/react/FilterableList.jsx index 90bfbe824..c06e0eb3a 100644 --- a/helpers/react/FilterableList.jsx +++ b/helpers/react/FilterableList.jsx @@ -28,6 +28,7 @@ type Props = { filterPlaceholder?: string, renderFilter?: () => React$Node, onFilterKeyDown?: (event: any) => void, // SyntheticKeyboardEvent + filterInputRef?: any, // Ref for the filter input element // Filter function - defaults to case-insensitive search on item label filterFunction?: (item: any, filterText: string) => boolean, getItemLabel?: (item: any) => string, // Used by default filter function @@ -64,6 +65,7 @@ export function FilterableList({ filterPlaceholder = 'Filter...', renderFilter, onFilterKeyDown, + filterInputRef, filterFunction, getItemLabel, optionKeyDecoration, @@ -116,6 +118,7 @@ export function FilterableList({ ) : (
void} onIdleTimeout - The function to execute when the user is idle. + */ + +type IdleTimerProps = {| + idleTime: number, + onIdleTimeout: () => void, +|}; + +const msToMinutes = (ms: number): number => Math.round(ms / 1000 / 60) + +// When the computer goes to sleep and wakes up, it can fire multiple queued events at once. +// We only want to execute the onIdleTimeout function once, so we try to ignore events that seem to have happened during sleep/wake +const LEGAL_DRIFT_THRESHHOLD = 10000 // 10 seconds + +/** + * IdleTimer component to keep track of user idle time and perform an action when the user is idle. + * @param {IdleTimerProps} props - Component props. + * @returns {React.Node} The IdleTimer component. + */ +function IdleTimer({ idleTime, onIdleTimeout }: IdleTimerProps): React$Node { + const [lastActivity, setLastActivity] = useState(Date.now()) + + useEffect(() => { + const handleUserActivity = () => { + setLastActivity(Date.now()) + } + + const handleVisibilityChange = () => { + // $FlowIgnore + if (document.visibilityState === 'visible') { + setLastActivity(Date.now()) + } + } + + window.addEventListener('mousemove', handleUserActivity) + window.addEventListener('keydown', handleUserActivity) + window.addEventListener('scroll', handleUserActivity) + // $FlowIgnore + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + window.removeEventListener('mousemove', handleUserActivity) + window.removeEventListener('keydown', handleUserActivity) + window.removeEventListener('scroll', handleUserActivity) + // $FlowIgnore + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, []) + + useEffect(() => { + const interval = setInterval(() => { + const elapsedMs = Date.now() - lastActivity + if (elapsedMs >= idleTime) { + if ((elapsedMs - LEGAL_DRIFT_THRESHHOLD) < idleTime) { + onIdleTimeout() + logDebug('IdleTimer', `${dt().padEnd(19)} Over the ${msToMinutes(idleTime)}m limit (it's been ${getTimeAgoString(new Date(lastActivity))}), calling onIdleTimeout`) + } else { + logDebug('IdleTimer', `${dt().padEnd(19)} Over the ${msToMinutes(idleTime)}m limit (it's been ${getTimeAgoString(new Date(lastActivity))}), NOT calling onIdleTimeout (computer was probably asleep); Resetting timer...`) + } + setLastActivity(Date.now()) // Reset the timer after calling onIdleTimeout + } else { + // logDebug('IdleTimer', `${dt().padEnd(19)} Still under the ${msToMinutes(idleTime)}m limit; It has been ${(Date.now() - lastActivity) / 1000}s since last activity`) + } + }, /* idleTime */ 15000) + + return () => { + clearInterval(interval) + } + }, [lastActivity, idleTime, onIdleTimeout]) + + return null +} + +export default IdleTimer + From de74eaefd52ad92157ddc958ae8cecc75dad2210 Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Tue, 30 Dec 2025 18:38:25 -0800 Subject: [PATCH 07/31] Fix: Break long line in NoteChooser dependency array to meet 180 char limit --- helpers/react/DynamicDialog/NoteChooser.jsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/helpers/react/DynamicDialog/NoteChooser.jsx b/helpers/react/DynamicDialog/NoteChooser.jsx index f76882be8..00d762d2c 100644 --- a/helpers/react/DynamicDialog/NoteChooser.jsx +++ b/helpers/react/DynamicDialog/NoteChooser.jsx @@ -314,7 +314,19 @@ export function NoteChooser({ return shouldInclude }) - }, [notes, includeCalendarNotes, includePersonalNotes, includeRelativeNotes, includeTeamspaceNotes, folderFilter, startFolder, filterByType, allowBackwardsCompatible, value, spaceFilter]) + }, [ + notes, + includeCalendarNotes, + includePersonalNotes, + includeRelativeNotes, + includeTeamspaceNotes, + folderFilter, + startFolder, + filterByType, + allowBackwardsCompatible, + value, + spaceFilter, + ]) // Add "New Note" option to items if includeNewNoteOption is true const itemsWithNewNote = useMemo(() => { From 2f9c7ddd8b1ece0a580ee23afee81ba2030836a4 Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Tue, 30 Dec 2025 23:24:48 -0800 Subject: [PATCH 08/31] Fix for idletimer so it doesn't log every minute forever --- helpers/react/IdleTimer.jsx | 38 +++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/helpers/react/IdleTimer.jsx b/helpers/react/IdleTimer.jsx index 3ce73b17f..661ef2d1b 100644 --- a/helpers/react/IdleTimer.jsx +++ b/helpers/react/IdleTimer.jsx @@ -8,7 +8,7 @@ //------------------------------------------------------------------------------ // @flow -import { useEffect, useState } from 'react' +import { useEffect, useState, useRef } from 'react' import { logDebug } from '@helpers/react/reactDev' import { getTimeAgoString } from '@helpers/dateTime.js' import { dt } from '@helpers/dev' @@ -38,16 +38,27 @@ const LEGAL_DRIFT_THRESHHOLD = 10000 // 10 seconds */ function IdleTimer({ idleTime, onIdleTimeout }: IdleTimerProps): React$Node { const [lastActivity, setLastActivity] = useState(Date.now()) + const hasCalledTimeoutRef = useRef(false) + const onIdleTimeoutRef = useRef(onIdleTimeout) + + // Keep the callback ref up to date + useEffect(() => { + onIdleTimeoutRef.current = onIdleTimeout + }, [onIdleTimeout]) useEffect(() => { const handleUserActivity = () => { setLastActivity(Date.now()) + // Reset the timeout flag when user becomes active + hasCalledTimeoutRef.current = false } const handleVisibilityChange = () => { // $FlowIgnore if (document.visibilityState === 'visible') { setLastActivity(Date.now()) + // Reset the timeout flag when user becomes active + hasCalledTimeoutRef.current = false } } @@ -67,16 +78,27 @@ function IdleTimer({ idleTime, onIdleTimeout }: IdleTimerProps): React$Node { }, []) useEffect(() => { + // Don't run interval if timeout has already fired and user hasn't interacted yet + if (hasCalledTimeoutRef.current) { + return + } + const interval = setInterval(() => { const elapsedMs = Date.now() - lastActivity if (elapsedMs >= idleTime) { - if ((elapsedMs - LEGAL_DRIFT_THRESHHOLD) < idleTime) { - onIdleTimeout() - logDebug('IdleTimer', `${dt().padEnd(19)} Over the ${msToMinutes(idleTime)}m limit (it's been ${getTimeAgoString(new Date(lastActivity))}), calling onIdleTimeout`) - } else { - logDebug('IdleTimer', `${dt().padEnd(19)} Over the ${msToMinutes(idleTime)}m limit (it's been ${getTimeAgoString(new Date(lastActivity))}), NOT calling onIdleTimeout (computer was probably asleep); Resetting timer...`) + // Only call timeout once per idle period + if (!hasCalledTimeoutRef.current) { + if ((elapsedMs - LEGAL_DRIFT_THRESHHOLD) < idleTime) { + hasCalledTimeoutRef.current = true + onIdleTimeoutRef.current() + logDebug('IdleTimer', `${dt().padEnd(19)} Over the ${msToMinutes(idleTime)}m limit (it's been ${getTimeAgoString(new Date(lastActivity))}), calling onIdleTimeout`) + } else { + logDebug('IdleTimer', `${dt().padEnd(19)} Over the ${msToMinutes(idleTime)}m limit (it's been ${getTimeAgoString(new Date(lastActivity))}), NOT calling onIdleTimeout (computer was probably asleep); Resetting timer...`) + // Reset lastActivity for sleep/wake case, but don't set hasCalledTimeoutRef + setLastActivity(Date.now()) + } + // Don't reset lastActivity here - let it stay idle so interval stops } - setLastActivity(Date.now()) // Reset the timer after calling onIdleTimeout } else { // logDebug('IdleTimer', `${dt().padEnd(19)} Still under the ${msToMinutes(idleTime)}m limit; It has been ${(Date.now() - lastActivity) / 1000}s since last activity`) } @@ -85,7 +107,7 @@ function IdleTimer({ idleTime, onIdleTimeout }: IdleTimerProps): React$Node { return () => { clearInterval(interval) } - }, [lastActivity, idleTime, onIdleTimeout]) + }, [lastActivity, idleTime]) return null } From c19a7f5b3163e78ed7b4f3aa0fdf8b7b11640a32 Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Tue, 30 Dec 2025 23:25:44 -0800 Subject: [PATCH 09/31] async bug fix --- .../src/components/FavoritesView.jsx | 170 +++++++++--------- 1 file changed, 82 insertions(+), 88 deletions(-) diff --git a/dwertheimer.Favorites/src/components/FavoritesView.jsx b/dwertheimer.Favorites/src/components/FavoritesView.jsx index e16acef7b..0032a1d1b 100644 --- a/dwertheimer.Favorites/src/components/FavoritesView.jsx +++ b/dwertheimer.Favorites/src/components/FavoritesView.jsx @@ -255,69 +255,67 @@ function FavoritesViewComponent({ // Handle adding favorite note dialog const handleAddNoteDialogSave = useCallback( - (updatedSettings: { [key: string]: any }) => { - await(async () => { - try { - if (updatedSettings.note) { - const filename = updatedSettings.note - - // Close dialog immediately - setShowAddNoteDialog(false) - setAddNoteDialogData({}) - - // Add the favorite - // Note: requestFromPlugin resolves with result.data (unwrapped), or rejects on error - // If we get here without throwing, the request succeeded - const response = await requestFromPlugin('addFavoriteNote', { filename }) - logDebug('FavoritesView', `addFavoriteNote response:`, response) - - // Show success toast - dispatch('SHOW_TOAST', { - type: 'SUCCESS', - msg: 'Favorite note added successfully', - timeout: 3000, - }) - - // Reload the favorites list first - await loadFavoriteNotes() - - // Wait for the note to appear in the list by checking the actual list data - // We need to reload and check, since state updates are async - const found = await waitForCondition( - async () => { - // Reload notes to get fresh data, then check - if (showNotes) { - const notes = await requestFromPlugin('getFavoriteNotes') - if (Array.isArray(notes)) { - return notes.some((note) => note.filename === filename) - } - } - return false - }, - { maxWaitMs: 3000, checkIntervalMs: 150 }, - ) + async (updatedSettings: { [key: string]: any }) => { + try { + if (updatedSettings.note) { + const filename = updatedSettings.note - // Reload one more time to ensure UI is in sync - await loadFavoriteNotes() + // Close dialog immediately + setShowAddNoteDialog(false) + setAddNoteDialogData({}) - // Set the newly added filename for highlighting (useEffect will handle scrolling) - setNewlyAddedFilename(filename) + // Add the favorite + // Note: requestFromPlugin resolves with result.data (unwrapped), or rejects on error + // If we get here without throwing, the request succeeded + const response = await requestFromPlugin('addFavoriteNote', { filename }) + logDebug('FavoritesView', `addFavoriteNote response:`, response) - if (found) { - logDebug('FavoritesView', 'Successfully added favorite note and found it in list') - } else { - logError('FavoritesView', 'Added favorite note but could not find it in list after waiting') - } - } - } catch (error) { - logError('FavoritesView', `Error adding favorite note: ${error.message}`) + // Show success toast dispatch('SHOW_TOAST', { - type: 'ERROR', - msg: `Error adding favorite: ${error.message}`, + type: 'SUCCESS', + msg: 'Favorite note added successfully', timeout: 3000, }) + + // Reload the favorites list first + await loadFavoriteNotes() + + // Wait for the note to appear in the list by checking the actual list data + // We need to reload and check, since state updates are async + const found = await waitForCondition( + async () => { + // Reload notes to get fresh data, then check + if (showNotes) { + const notes = await requestFromPlugin('getFavoriteNotes') + if (Array.isArray(notes)) { + return notes.some((note) => note.filename === filename) + } + } + return false + }, + { maxWaitMs: 3000, checkIntervalMs: 150 }, + ) + + // Reload one more time to ensure UI is in sync + await loadFavoriteNotes() + + // Set the newly added filename for highlighting (useEffect will handle scrolling) + setNewlyAddedFilename(filename) + + if (found) { + logDebug('FavoritesView', 'Successfully added favorite note and found it in list') + } else { + logError('FavoritesView', 'Added favorite note but could not find it in list after waiting') + } } - })() + } catch (error) { + logError('FavoritesView', `Error adding favorite note: ${error.message}`) + dispatch('SHOW_TOAST', { + type: 'ERROR', + msg: `Error adding favorite: ${error.message}`, + timeout: 3000, + }) + } }, [requestFromPlugin, loadFavoriteNotes, dispatch, showNotes, favoriteNotes], ) @@ -337,28 +335,26 @@ function FavoritesViewComponent({ // Handle adding favorite command dialog const handleAddCommandDialogSave = useCallback( - (updatedSettings: { [key: string]: any }) => { - await(async () => { - try { - if (updatedSettings.preset && updatedSettings.commandName && updatedSettings.url) { - const response = await requestFromPlugin('addFavoriteCommand', { - jsFunction: updatedSettings.preset, - name: updatedSettings.commandName, - data: updatedSettings.url, - }) - if (response && response.success) { - await loadFavoriteCommands() - setShowAddCommandDialog(false) - setAddCommandDialogData({}) - logDebug('FavoritesView', 'Successfully added favorite command') - } else { - logError('FavoritesView', `Failed to add favorite command: ${response?.message || 'Unknown error'}`) - } + async (updatedSettings: { [key: string]: any }) => { + try { + if (updatedSettings.preset && updatedSettings.commandName && updatedSettings.url) { + const response = await requestFromPlugin('addFavoriteCommand', { + jsFunction: updatedSettings.preset, + name: updatedSettings.commandName, + data: updatedSettings.url, + }) + if (response && response.success) { + await loadFavoriteCommands() + setShowAddCommandDialog(false) + setAddCommandDialogData({}) + logDebug('FavoritesView', 'Successfully added favorite command') + } else { + logError('FavoritesView', `Failed to add favorite command: ${response?.message || 'Unknown error'}`) } - } catch (error) { - logError('FavoritesView', `Error adding favorite command: ${error.message}`) } - })() + } catch (error) { + logError('FavoritesView', `Error adding favorite command: ${error.message}`) + } }, [requestFromPlugin, loadFavoriteCommands], ) @@ -369,20 +365,18 @@ function FavoritesViewComponent({ }, []) const handleAddCommandButtonClick = useCallback( - (key: string, value: string) => { + async (key: string, value: string) => { if (key === 'getCallbackURL') { - await(async () => { - try { - const urlResponse = await requestFromPlugin('getCallbackURL', {}) - if (urlResponse && urlResponse.success && urlResponse.url) { - // Update the URL field in the dialog - setAddCommandDialogData((prev) => ({ ...prev, url: urlResponse.url })) - logDebug('FavoritesView', `Got URL from Link Creator: ${urlResponse.url}`) - } - } catch (error) { - logError('FavoritesView', `Error getting callback URL: ${error.message}`) + try { + const urlResponse = await requestFromPlugin('getCallbackURL', {}) + if (urlResponse && urlResponse.success && urlResponse.url) { + // Update the URL field in the dialog + setAddCommandDialogData((prev) => ({ ...prev, url: urlResponse.url })) + logDebug('FavoritesView', `Got URL from Link Creator: ${urlResponse.url}`) } - })() + } catch (error) { + logError('FavoritesView', `Error getting callback URL: ${error.message}`) + } return false // Don't close dialog } }, From 82006b546bd8776f73f0a1b04e08f466a555ea89 Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Tue, 30 Dec 2025 23:27:35 -0800 Subject: [PATCH 10/31] make descriptions smaller and less prominent --- helpers/react/DynamicDialog/DynamicDialog.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/helpers/react/DynamicDialog/DynamicDialog.css b/helpers/react/DynamicDialog/DynamicDialog.css index 01fd8d446..ab36f0c6d 100644 --- a/helpers/react/DynamicDialog/DynamicDialog.css +++ b/helpers/react/DynamicDialog/DynamicDialog.css @@ -471,7 +471,8 @@ /* Style for item description */ .item-description { - font-size: small; + font-size: smaller; + font-style: italic; color: var(--fg-alt-color); /* margin-top: 0.3rem; */ opacity: 0.8; From 508e8b4d75029946d9c87d776b8f8b09ffa546ec Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Tue, 30 Dec 2025 23:28:17 -0800 Subject: [PATCH 11/31] Show title only param on NoteChooser --- .../src/components/FieldEditor.jsx | 17 +++++++++++++++++ helpers/react/DynamicDialog/DynamicDialog.jsx | 1 + helpers/react/DynamicDialog/NoteChooser.jsx | 6 +++--- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/dwertheimer.Forms/src/components/FieldEditor.jsx b/dwertheimer.Forms/src/components/FieldEditor.jsx index 146da255d..97e4021dc 100644 --- a/dwertheimer.Forms/src/components/FieldEditor.jsx +++ b/dwertheimer.Forms/src/components/FieldEditor.jsx @@ -622,6 +622,23 @@ export function FieldEditor({ field, allFields, onSave, onCancel, requestFromPlu
When enabled, displays the short description (e.g., folder path, space name) on a second line below the label
+
+ +
+ When enabled, displays only the note title in the label (not "path / title"). The path will still appear in the short description if enabled. +
+
)} diff --git a/helpers/react/DynamicDialog/DynamicDialog.jsx b/helpers/react/DynamicDialog/DynamicDialog.jsx index 17179db7f..609d53669 100644 --- a/helpers/react/DynamicDialog/DynamicDialog.jsx +++ b/helpers/react/DynamicDialog/DynamicDialog.jsx @@ -110,6 +110,7 @@ export type TSettingItem = { includeNewNoteOption?: boolean, // for note-chooser, add a 'New Note' option that allows creating a new note dependsOnFolderKey?: string, // DEPRECATED: use sourceFolderKey instead. For note-chooser, key of a folder-chooser field to filter notes by folder (value dependency) sourceFolderKey?: string, // Value dependency: for note-chooser, key of a folder-chooser field to filter notes by folder + showTitleOnly?: boolean, // for note-chooser, show only the note title in the label (not "path / title") (default: false) // showValue option for SearchableChooser-based fields showValue?: boolean, // for folder-chooser, note-chooser, heading-chooser, dropdown-select-chooser: show the selected value below the input (default: false) // space-chooser options diff --git a/helpers/react/DynamicDialog/NoteChooser.jsx b/helpers/react/DynamicDialog/NoteChooser.jsx index 00d762d2c..1ba42e1c3 100644 --- a/helpers/react/DynamicDialog/NoteChooser.jsx +++ b/helpers/react/DynamicDialog/NoteChooser.jsx @@ -228,7 +228,7 @@ export function NoteChooser({ } const normalizedStart = normalizeFolder(startFolder) const normalizedNoteFolder = normalizeFolder(noteFolder) - const folderMatches = normalizedNoteFolder === normalizedStart || normalizedNoteFolder.startsWith(normalizedStart + '/') + const folderMatches = normalizedNoteFolder === normalizedStart || normalizedNoteFolder.startsWith(`${normalizedStart}/`) if (!folderMatches) { return false } @@ -259,7 +259,7 @@ export function NoteChooser({ // Check if note is in the selected folder // For exact match or if note folder starts with filter folder + '/' - const folderMatches = normalizedNoteFolder === normalizedFilter || normalizedNoteFolder.startsWith(normalizedFilter + '/') + const folderMatches = normalizedNoteFolder === normalizedFilter || normalizedNoteFolder.startsWith(`${normalizedFilter}/`) if (!folderMatches) { return false // Exclude notes not in the selected folder @@ -374,7 +374,7 @@ export function NoteChooser({ // Parse teamspace info to get clean folder path const possTeamspaceDetails = parseTeamspaceFilename(note.filename) let folder = getFolderFromFilename(note.filename) - + // Strip teamspace prefix from folder path for display if (possTeamspaceDetails.isTeamspace) { folder = getFilenameWithoutTeamspaceID(folder) || '/' From 20aa9725445b13e46f3d17f7ad24c399763d231e Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Wed, 31 Dec 2025 00:39:09 -0800 Subject: [PATCH 12/31] Add autosave field to DynamicDialog with form title support - Add new AutosaveField component for automatic periodic form state saving - Support form title and window title in autosave filename patterns (, ) - Add invisible prop to hide autosave UI while still performing saves - Change default autosave interval from 5 to 2 seconds - Fix trash folder note finding by using searchAllFolders parameter - Add autosave field type to Form Builder with configuration UI - Include lastUpdated timestamp in saved form state - Use windowTitle as fallback when formTitle is empty - Support code block replacement for autosave content in same note --- .../src/components/FieldEditor.jsx | 75 ++++- dwertheimer.Forms/src/components/FormView.jsx | 1 + .../src/components/fieldTypes.js | 1 + dwertheimer.Forms/src/requestHandlers.js | 152 +++++++++- helpers/react/DynamicDialog/AutosaveField.jsx | 284 ++++++++++++++++++ helpers/react/DynamicDialog/DynamicDialog.css | 53 ++++ helpers/react/DynamicDialog/DynamicDialog.jsx | 8 + helpers/react/DynamicDialog/InputBox.jsx | 2 +- .../DynamicDialog/dialogElementRenderer.js | 66 ++-- 9 files changed, 620 insertions(+), 22 deletions(-) create mode 100644 helpers/react/DynamicDialog/AutosaveField.jsx diff --git a/dwertheimer.Forms/src/components/FieldEditor.jsx b/dwertheimer.Forms/src/components/FieldEditor.jsx index 97e4021dc..01d02aaae 100644 --- a/dwertheimer.Forms/src/components/FieldEditor.jsx +++ b/dwertheimer.Forms/src/components/FieldEditor.jsx @@ -185,7 +185,7 @@ export function FieldEditor({ field, allFields, onSave, onCancel, requestFromPlu onSave(editedField) } - const needsKey = editedField.type !== 'separator' && editedField.type !== 'heading' + const needsKey = editedField.type !== 'separator' && editedField.type !== 'heading' && editedField.type !== 'autosave' // Construct header title with label, key, and type const headerTitle = needsKey && editedField.key ? `Editing ${editedField.type}: ${editedField.label || ''} (${editedField.key})` : `Editing: ${editedField.type}` @@ -224,7 +224,7 @@ export function FieldEditor({ field, allFields, onSave, onCancel, requestFromPlu )} - {editedField.type !== 'separator' && editedField.type !== 'heading' && editedField.type !== 'calendarpicker' && ( + {editedField.type !== 'separator' && editedField.type !== 'heading' && editedField.type !== 'calendarpicker' && editedField.type !== 'autosave' && (
{validationError && ( -
+
{validationError} {/* Display the validation error message */}
)} diff --git a/helpers/react/DynamicDialog/dialogElementRenderer.js b/helpers/react/DynamicDialog/dialogElementRenderer.js index 8c989b571..05d78c024 100644 --- a/helpers/react/DynamicDialog/dialogElementRenderer.js +++ b/helpers/react/DynamicDialog/dialogElementRenderer.js @@ -25,6 +25,7 @@ import MultiSelectChooser from './MultiSelectChooser.jsx' import { ExpandableTextarea } from './ExpandableTextarea.jsx' import { TemplateJSBlock } from './TemplateJSBlock.jsx' import { MarkdownPreview } from './MarkdownPreview.jsx' +import AutosaveField from './AutosaveField.jsx' import type { TSettingItem, TSettingItemType } from './DynamicDialog.jsx' import type { Option } from './DropdownSelect.jsx' import { Button, ButtonGroup } from './ButtonComponents.jsx' @@ -57,6 +58,7 @@ type RenderItemProps = { updatedSettings?: { [key: string]: any }, // For heading-chooser to watch note-chooser field onFoldersChanged?: () => void, // Callback to reload folders after creating a new folder onNotesChanged?: () => void, // Callback to reload notes after creating a new note + formTitle?: string, // Form title for autosave field } /** @@ -88,6 +90,7 @@ export function renderItem({ updatedSettings, // For heading-chooser to watch note-chooser field onFoldersChanged, // Callback to reload folders after creating a new folder onNotesChanged, // Callback to reload notes after creating a new note + formTitle, // Form title for autosave field }: RenderItemProps): React$Node { const element = () => { const thisLabel = item.label || '?' @@ -245,11 +248,11 @@ export function renderItem({ options={(normalizedOptions: Array)} value={item.value || item.default || ''} onChange={(value: string) => { - // Don't submit placeholder (empty value) - if (value !== '') { - item.key && handleFieldChange(item.key, value) - item.key && handleComboChange(item.key, value) - } + // Don't submit placeholder (empty value) + if (value !== '') { + item.key && handleFieldChange(item.key, value) + item.key && handleComboChange(item.key, value) + } }} disabled={disabled} compactDisplay={compactDisplay} @@ -424,7 +427,11 @@ export function renderItem({ ) : null return ( -
+
{labelElement} { logDebug('dialogElementRenderer', `folder-chooser: handleFolderChange called with folder="${folder}"`) @@ -590,12 +600,7 @@ export function renderItem({ const compactDisplay = item.compactDisplay || false // Handle both string (ID) and object (full event) values for backward compatibility const rawValue = item.value || item.default - const currentValue = - typeof rawValue === 'string' - ? rawValue - : rawValue && typeof rawValue === 'object' && rawValue.id - ? rawValue.id - : '' + const currentValue = typeof rawValue === 'string' ? rawValue : rawValue && typeof rawValue === 'object' && rawValue.id ? rawValue.id : '' const eventDate = item.eventDate // Support both old (dependsOnDateKey) and new (sourceDateKey) property names for backward compatibility const sourceDateKey = item.sourceDateKey ?? item.dependsOnDateKey @@ -606,7 +611,12 @@ export function renderItem({ const dateValue = updatedSettings[sourceDateKey] if (dateValue !== null && dateValue !== undefined) { dateFromField = dateValue - logDebug('dialogElementRenderer', `event-chooser: got date from ${sourceDateKey}: ${typeof dateValue === 'string' ? dateValue : dateValue instanceof Date ? dateValue.toISOString() : String(dateValue)}`) + logDebug( + 'dialogElementRenderer', + `event-chooser: got date from ${sourceDateKey}: ${ + typeof dateValue === 'string' ? dateValue : dateValue instanceof Date ? dateValue.toISOString() : String(dateValue) + }`, + ) } } @@ -626,9 +636,7 @@ export function renderItem({ ...event, date: event.date instanceof Date ? event.date.toISOString() : event.date, endDate: event.endDate instanceof Date ? event.endDate.toISOString() : event.endDate, - occurrences: event.occurrences - ? event.occurrences.map((d: Date | string) => (d instanceof Date ? d.toISOString() : d)) - : [], + occurrences: event.occurrences ? event.occurrences.map((d: Date | string) => (d instanceof Date ? d.toISOString() : d)) : [], } handleFieldChange(item.key, serializedEvent) } @@ -781,6 +789,30 @@ export function renderItem({
) } + case 'autosave': { + const label = item.label || '' + const compactDisplay = item.compactDisplay || false + const autosaveInterval = item.autosaveInterval ?? 2 + const autosaveFilename = item.autosaveFilename + const invisible = (item: any).invisible || false + + return ( +
+ +
+ ) + } default: return null } From a23241b96a59e06db98cf4a1168fa6744d4659bc Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Wed, 31 Dec 2025 00:57:51 -0800 Subject: [PATCH 13/31] Fix favorites sidebar scroll offset and window options - Add toolbar height offset calculation when scrolling to top in FavoritesView - Use same calculation as Toast.css: calc(1rem + var(--noteplan-toolbar-height, 0)) - Add showReloadButton: false to hide reload button in favorites sidebar - Change icon color to use theme variable var(--tint-color) instead of hardcoded blue-500 --- .../src/components/FavoritesView.jsx | 39 +++++++++++++++++-- dwertheimer.Favorites/src/windowManagement.js | 3 +- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/dwertheimer.Favorites/src/components/FavoritesView.jsx b/dwertheimer.Favorites/src/components/FavoritesView.jsx index 0032a1d1b..14c3c1c47 100644 --- a/dwertheimer.Favorites/src/components/FavoritesView.jsx +++ b/dwertheimer.Favorites/src/components/FavoritesView.jsx @@ -477,16 +477,49 @@ function FavoritesViewComponent({ setSelectedIndex(null) // Scroll list to top and focus the filter input after a brief delay to ensure it's rendered setTimeout(() => { + // Get toolbar height offset (same calculation as Toast.css: calc(1rem + var(--noteplan-toolbar-height, 0))) + const root = document.documentElement + if (!root) return + + const toolbarHeight = parseInt(getComputedStyle(root).getPropertyValue('--noteplan-toolbar-height') || '0', 10) + const oneRem = parseFloat(getComputedStyle(root).fontSize || '16px') + const scrollOffset = oneRem + toolbarHeight + + // Helper function to find scrollable ancestor + const findScrollableAncestor = (el: HTMLElement): ?HTMLElement => { + let parent: ?Element = el.parentElement + while (parent) { + if (parent instanceof HTMLElement) { + const style = getComputedStyle(parent) + if (style.overflow === 'auto' || style.overflow === 'scroll' || style.overflowY === 'auto' || style.overflowY === 'scroll') { + return parent + } + } + parent = parent.parentElement + } + return null + } + // Scroll list to top if (listRef.current) { const firstItem = listRef.current.querySelector('[data-index="0"]') if (firstItem instanceof HTMLElement) { - firstItem.scrollIntoView({ block: 'start', behavior: 'instant' }) + // Use scrollIntoView with offset by scrolling the parent container + const scrollableParent = findScrollableAncestor(firstItem) + if (scrollableParent) { + const itemRect = firstItem.getBoundingClientRect() + const parentRect = scrollableParent.getBoundingClientRect() + const currentScrollTop = scrollableParent.scrollTop + const targetScrollTop = currentScrollTop + (itemRect.top - parentRect.top) - scrollOffset + scrollableParent.scrollTop = Math.max(0, targetScrollTop) + } else { + firstItem.scrollIntoView({ block: 'start', behavior: 'instant' }) + } } else if (listRef.current instanceof HTMLElement) { - // If no items, try scrolling the container itself + // If no items, try scrolling the container itself with offset const scrollableParent = listRef.current.parentElement?.parentElement if (scrollableParent instanceof HTMLElement && scrollableParent.scrollTop !== undefined) { - scrollableParent.scrollTop = 0 + scrollableParent.scrollTop = scrollOffset } } } diff --git a/dwertheimer.Favorites/src/windowManagement.js b/dwertheimer.Favorites/src/windowManagement.js index b1648e712..33c5f99d0 100644 --- a/dwertheimer.Favorites/src/windowManagement.js +++ b/dwertheimer.Favorites/src/windowManagement.js @@ -140,8 +140,9 @@ export async function openFavoritesBrowser(_isFloating: boolean | string = false // Options for showInMainWindow (main window mode) splitView: false, icon: 'star', - iconColor: 'blue-500', + iconColor: 'var(--tint-color, #dc8a78)', autoTopPadding: true, + showReloadButton: false, } // Choose the appropriate command based on whether it's floating or main window From 070412ce15d4ff34faf3f1770f4d36182555e4ec Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Wed, 31 Dec 2025 01:12:52 -0800 Subject: [PATCH 14/31] Use theme color variable for favorites sidebar icon - Change iconColor from hardcoded 'blue-500' to theme variable 'var(--tint-color, #dc8a78)' --- dwertheimer.Favorites/src/windowManagement.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/dwertheimer.Favorites/src/windowManagement.js b/dwertheimer.Favorites/src/windowManagement.js index 33c5f99d0..b44e2965a 100644 --- a/dwertheimer.Favorites/src/windowManagement.js +++ b/dwertheimer.Favorites/src/windowManagement.js @@ -97,6 +97,17 @@ export async function openFavoritesBrowser(_isFloating: boolean | string = false \n` + const themeCSS = generateCSSFromTheme() + // find the --tint-color from the themeCSS + const tintColor = themeCSS.match(/--tint-color: (.*?);/)?.[1] + let iconColorHex = '' + if (tintColor) { + logDebug(pluginJson, `openFavoritesBrowser: Found tint color: ${tintColor}`) + iconColorHex = tintColor + } else { + logDebug(pluginJson, `openFavoritesBrowser: No tint color found in themeCSS`) + } + const windowOptions = { savedFilename: `../../${pluginJson['plugin.id']}/favorites_browser_output.html` /* for saving a debug version of the html file */, headerTags: cssTagsString, @@ -105,7 +116,7 @@ export async function openFavoritesBrowser(_isFloating: boolean | string = false height: 800, customId: windowId, // Use unique window ID instead of constant shouldFocus: true, - generalCSSIn: generateCSSFromTheme(), + generalCSSIn: themeCSS, specificCSS: ` /* Favorites browser - left justified, full height, expandable width */ body, html { @@ -140,7 +151,7 @@ export async function openFavoritesBrowser(_isFloating: boolean | string = false // Options for showInMainWindow (main window mode) splitView: false, icon: 'star', - iconColor: 'var(--tint-color, #dc8a78)', + iconColor: iconColorHex ? iconColorHex : 'blue-500', autoTopPadding: true, showReloadButton: false, } From 677f95ec6710fe8eae6b439272483baee16cdaa2 Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Wed, 31 Dec 2025 09:14:51 -0800 Subject: [PATCH 15/31] Add autosave and restore functionality for forms - Add AutosaveField component with debounced autosave (default 2s interval) - Autosave saves form state to code block in @Trash by default - Support for custom autosave filename patterns with and placeholders - Add invisible autosave option for silent background saves - Add global autosave setting in plugin.json (default: true) to auto-add invisible autosave to all forms - Add 'Restore form from autosave' command to restore form with saved data - Autosave includes form title, template filename, and template title for restoration - Add x-callback URL restore link in autosave notes (inserted after title, preserving note structure) - Fix debounce to prevent saving on every keystroke - Fix @Trash folder note search to find existing autosave files - Add final autosave before form submission - Pass templateFilename and templateTitle through to autosave field - Pass defaultValues to DynamicDialog for form pre-population on restore --- dwertheimer.Forms/plugin.json | 17 ++ dwertheimer.Forms/src/NPTemplateForm.js | 175 +++++++++++++++++- .../src/components/FieldEditor.jsx | 19 +- dwertheimer.Forms/src/components/FormView.jsx | 136 +++++++------- dwertheimer.Forms/src/index.js | 2 +- dwertheimer.Forms/src/requestHandlers.js | 114 +++++++++++- dwertheimer.Forms/src/windowManagement.js | 26 ++- helpers/react/DynamicDialog/AutosaveField.jsx | 127 ++++++++----- helpers/react/DynamicDialog/DynamicDialog.jsx | 72 +++++-- .../DynamicDialog/dialogElementRenderer.js | 11 ++ 10 files changed, 548 insertions(+), 151 deletions(-) diff --git a/dwertheimer.Forms/plugin.json b/dwertheimer.Forms/plugin.json index c0489b55a..a0c7fa41e 100644 --- a/dwertheimer.Forms/plugin.json +++ b/dwertheimer.Forms/plugin.json @@ -66,6 +66,16 @@ } ] }, + { + "name": "Restore form from autosave", + "alias": [ + "restoreautosave", + "restoreform" + ], + "description": "Restore a form from an autosave file, opening the form with the saved data pre-populated", + "jsFunction": "restoreFormFromAutosave", + "arguments": ["Autosave filename (e.g., '@Trash/Autosave-2025-12-30T23-51-09')"] + }, { "name": "Create Processing Template", "alias": [ @@ -166,6 +176,13 @@ "default": "dwertheimer.Forms", "COMMENT": "This is for use by the editSettings helper function. PluginID must match the plugin.id in the top of this file" }, + { + "key": "autosave", + "type": "bool", + "title": "Enable Autosave for All Forms", + "description": "When enabled, automatically adds an invisible autosave field to every form opened. The form will be automatically saved periodically in the background, by default to @Trash/Autosave-. This provides a safety net in case of unexpected crashes or power outages or form submission errors. You can turn this off and add an autosave to a specific form by adding an autosave field to the form in the form builder.", + "default": true + }, { "note": "================== DEBUGGING SETTINGS ========================" }, diff --git a/dwertheimer.Forms/src/NPTemplateForm.js b/dwertheimer.Forms/src/NPTemplateForm.js index 69f16d867..ec194e212 100644 --- a/dwertheimer.Forms/src/NPTemplateForm.js +++ b/dwertheimer.Forms/src/NPTemplateForm.js @@ -16,6 +16,8 @@ import { updateFrontMatterVars, ensureFrontmatter, noteHasFrontMatter, getFrontm import { loadCodeBlockFromNote } from '@helpers/codeBlocks' import { generateCSSFromTheme } from '@helpers/NPThemeToCSS' import { parseTeamspaceFilename } from '@helpers/teamspace' +import { getFolderFromFilename } from '@helpers/folders' +import { displayTitle } from '@helpers/paragraph' // Note: getFoldersMatching is no longer used here - FormView loads folders dynamically via requestFromPlugin // Re-export shared type for backward compatibility @@ -275,6 +277,10 @@ export async function openTemplateForm(templateTitle?: string): Promise { frontmatterAttributes.formFields = formFields } + // Add templateFilename and templateTitle for autosave identification + frontmatterAttributes.templateFilename = selectedTemplate + frontmatterAttributes.templateTitle = templateNote.title || '' + if (await validateFormFields(frontmatterAttributes.formFields)) { await openFormWindow(frontmatterAttributes) } else { @@ -780,11 +786,178 @@ export async function triggerOpenForm(): Promise { // Open the template form with the note's title await openTemplateForm(noteTitle) } catch (error) { - logError(pluginJson, `triggerOpenForm: Error: ${JSP(error)}`) + logError(pluginJson, `triggerOpenForm: Error: ${error.message}`) await showMessage(`Error opening form: ${error.message}`) } } +/** + * Restore form from autosave + * Opens the form with the autosaved data pre-populated + * @param {string} autosaveFilename - The filename of the autosave file (e.g., "@Trash/Autosave-2025-12-30T23-51-09") + * @returns {Promise} + */ +export async function restoreFormFromAutosave(autosaveFilename?: string): Promise { + try { + if (!autosaveFilename) { + await showMessage('No autosave filename provided') + return + } + + logDebug(pluginJson, `restoreFormFromAutosave: Restoring from "${autosaveFilename}"`) + + // Parse the autosave filename to get the note + const parts = autosaveFilename.split('/') + let folder = '/' + let noteTitle = autosaveFilename + + if (parts.length > 1) { + folder = parts.slice(0, -1).join('/') + noteTitle = parts[parts.length - 1] + } else if (autosaveFilename.startsWith('@')) { + noteTitle = autosaveFilename + folder = '/' + } + + // Find the autosave note + let note = null + const isTrashFolder = folder === '@Trash' || folder.startsWith('@Trash/') + + if (isTrashFolder) { + const potentialNotes = DataStore.projectNoteByTitle(noteTitle, true, true) ?? [] + const matchingNotes = potentialNotes.filter((n) => { + const noteFolder = getFolderFromFilename(n.filename) + return noteFolder === folder && displayTitle(n) === noteTitle + }) + if (matchingNotes.length > 0) { + note = matchingNotes[0] + } + } else { + const folderNotes = DataStore.projectNotes.filter((n) => { + const noteFolder = getFolderFromFilename(n.filename) + return noteFolder === folder && displayTitle(n) === noteTitle + }) + if (folderNotes.length > 0) { + note = folderNotes[0] + } + } + + if (!note) { + await showMessage(`Could not find autosave file: ${autosaveFilename}`) + return + } + + // Load the autosave data from the code block + const autosaveData = await loadCodeBlockFromNote(note, 'autosave', pluginJson.id, null) + if (!autosaveData) { + await showMessage(`No autosave data found in file: ${autosaveFilename}`) + return + } + + // Parse the autosave data to get form identification and default values + let formState: any = {} + let formTitle: string | null = null + let templateFilename: string | null = null + + try { + formState = JSON.parse(autosaveData) + + // Extract form identification from the saved data + formTitle = formState.__templateTitle__ || formState.__formTitle__ || null + templateFilename = formState.__templateFilename__ || null + + // Remove the internal fields from formState to get the actual form values + delete formState.__formTitle__ + delete formState.__templateFilename__ + delete formState.__templateTitle__ + delete formState.lastUpdated + } catch (e) { + logError(pluginJson, `restoreFormFromAutosave: Error parsing autosave data: ${e.message}`) + await showMessage(`Error parsing autosave data: ${e.message}`) + return + } + + // If we don't have form identification from the saved data, try to extract from filename + if (!formTitle) { + if (autosaveFilename.includes('-') && !autosaveFilename.startsWith('@Trash/Autosave-')) { + const match = autosaveFilename.match(/Autosave-([^-]+)-/) + if (match && match[1]) { + formTitle = match[1].replace(/-/g, ' ') + } + } + } + + // If we still don't have a form title, ask the user + if (!formTitle) { + const options = getFormTemplateList() + if (options.length === 0) { + await showMessage('No form templates found. Cannot restore form.') + return + } + const choice = await CommandBar.showOptions( + options.map((opt) => opt.label), + 'Restore Form from Autosave', + 'Select the form template to restore:', + ) + if (choice && choice.index >= 0 && choice.index < options.length) { + formTitle = options[choice.index].label + // Try to get templateFilename from the selected option + if (!templateFilename && options[choice.index].value) { + templateFilename = options[choice.index].value + } + } else { + return // User cancelled + } + } + + logDebug(pluginJson, `restoreFormFromAutosave: Opening form "${formTitle}" with restored data (${Object.keys(formState).length} fields)`) + + // Open the form with the restored data as default values + // We need to get the template note to pass to openFormWindow + let selectedTemplate = templateFilename + if (!selectedTemplate && formTitle) { + const options = getFormTemplateList() + const chosenOpt = options.find((option) => option.label === formTitle) + if (chosenOpt) { + selectedTemplate = chosenOpt.value + } + } + + if (!selectedTemplate) { + await showMessage(`Could not find template file for form "${formTitle}"`) + return + } + + const templateNote = await getNoteByFilename(selectedTemplate) + if (!templateNote) { + await showMessage(`Could not find template note: ${selectedTemplate}`) + return + } + + // Get form fields and frontmatter + const formFields = await loadCodeBlockFromNote>(selectedTemplate, 'formfields', pluginJson.id, parseObjectString) + if (!formFields) { + await showMessage(`Could not load form fields from template: ${selectedTemplate}`) + return + } + + const templateData = templateNote.content || '' + const { _, frontmatterAttributes } = await DataStore.invokePluginCommandByName('renderFrontmatter', 'np.Templating', [templateData]) + + // Add form fields, template info, and default values + frontmatterAttributes.formFields = formFields + frontmatterAttributes.templateFilename = selectedTemplate + frontmatterAttributes.templateTitle = templateNote.title || formTitle + frontmatterAttributes.defaultValues = formState // Pass the restored form state as default values + + // Open the form window with default values + await openFormWindow(frontmatterAttributes) + } catch (error) { + logError(pluginJson, `restoreFormFromAutosave: Error: ${error.message}`) + await showMessage(`Error restoring form from autosave: ${error.message}`) + } +} + /** * Export testRequestHandlers for direct testing */ diff --git a/dwertheimer.Forms/src/components/FieldEditor.jsx b/dwertheimer.Forms/src/components/FieldEditor.jsx index 01d02aaae..fe9971ab8 100644 --- a/dwertheimer.Forms/src/components/FieldEditor.jsx +++ b/dwertheimer.Forms/src/components/FieldEditor.jsx @@ -1293,26 +1293,13 @@ export function FieldEditor({ field, allFields, onSave, onCancel, requestFromPlu <formTitle> or <FORM_NAME> - Form title (sanitized for filesystem compatibility) - Default is "@Trash/Autosave-<formTitle>-<ISO8601>" (or "@Trash/Autosave-<ISO8601>" if no form title). The form title will be automatically included if available. + Default is "@Trash/Autosave-<formTitle>-<ISO8601>" (or "@Trash/Autosave-<ISO8601>" if no form title). The form title will be + automatically included if available.
-
-
-
diff --git a/dwertheimer.Forms/src/components/FormView.jsx b/dwertheimer.Forms/src/components/FormView.jsx index 8ae5457ff..e885ef100 100644 --- a/dwertheimer.Forms/src/components/FormView.jsx +++ b/dwertheimer.Forms/src/components/FormView.jsx @@ -94,77 +94,80 @@ export function FormView({ data, dispatch, reactSettings, setReactSettings, onSu * @param {number} timeout - Timeout in milliseconds (default: 10000) * @returns {Promise} */ - const requestFromPlugin = useCallback((command: string, dataToSend: any = {}, timeout: number = 10000): Promise => { - if (!command) throw new Error('requestFromPlugin: command must be called with a string') + const requestFromPlugin = useCallback( + (command: string, dataToSend: any = {}, timeout: number = 10000): Promise => { + if (!command) throw new Error('requestFromPlugin: command must be called with a string') - const correlationId = `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` - const requestStartTime = performance.now() - const pendingCount = pendingRequestsRef.current.size + const correlationId = `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + const requestStartTime = performance.now() + const pendingCount = pendingRequestsRef.current.size - logDebug('FormView', `[DIAG] requestFromPlugin START: command="${command}", correlationId="${correlationId}", pendingRequests=${pendingCount}`) + logDebug('FormView', `[DIAG] requestFromPlugin START: command="${command}", correlationId="${correlationId}", pendingRequests=${pendingCount}`) - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - const pending = pendingRequestsRef.current.get(correlationId) - if (pending) { - pendingRequestsRef.current.delete(correlationId) - const elapsed = performance.now() - requestStartTime - logDebug('FormView', `[DIAG] requestFromPlugin TIMEOUT: command="${command}", correlationId="${correlationId}", elapsed=${elapsed.toFixed(2)}ms`) - reject(new Error(`Request timeout: ${command}`)) - } - }, timeout) - - pendingRequestsRef.current.set(correlationId, { resolve, reject, timeoutId }) - - // Use requestAnimationFrame to yield to browser before dispatching - requestAnimationFrame(() => { - const dispatchElapsed = performance.now() - requestStartTime - logDebug( - 'FormView', - `[DIAG] requestFromPlugin DISPATCH: command="${command}", correlationId="${correlationId}", pendingRequests=${ - pendingRequestsRef.current.size - }, dispatchElapsed=${dispatchElapsed.toFixed(2)}ms`, - ) - - const requestData = { - ...dataToSend, - __correlationId: correlationId, - __requestType: 'REQUEST', - __windowId: pluginData?.windowId || '', // Include windowId in request for reliable response routing - } + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + const pending = pendingRequestsRef.current.get(correlationId) + if (pending) { + pendingRequestsRef.current.delete(correlationId) + const elapsed = performance.now() - requestStartTime + logDebug('FormView', `[DIAG] requestFromPlugin TIMEOUT: command="${command}", correlationId="${correlationId}", elapsed=${elapsed.toFixed(2)}ms`) + reject(new Error(`Request timeout: ${command}`)) + } + }, timeout) + + pendingRequestsRef.current.set(correlationId, { resolve, reject, timeoutId }) - // Dispatch the request + // Use requestAnimationFrame to yield to browser before dispatching requestAnimationFrame(() => { - const dispatchAfterRAFElapsed = performance.now() - requestStartTime + const dispatchElapsed = performance.now() - requestStartTime logDebug( 'FormView', - `[DIAG] requestFromPlugin DISPATCH AFTER RAF: command="${command}", correlationId="${correlationId}", dispatchElapsed=${dispatchAfterRAFElapsed.toFixed(2)}ms`, + `[DIAG] requestFromPlugin DISPATCH: command="${command}", correlationId="${correlationId}", pendingRequests=${ + pendingRequestsRef.current.size + }, dispatchElapsed=${dispatchElapsed.toFixed(2)}ms`, ) - dispatch('SEND_TO_PLUGIN', [command, requestData], `WebView: requestFromPlugin: ${String(command)}`) + + const requestData = { + ...dataToSend, + __correlationId: correlationId, + __requestType: 'REQUEST', + __windowId: pluginData?.windowId || '', // Include windowId in request for reliable response routing + } + + // Dispatch the request + requestAnimationFrame(() => { + const dispatchAfterRAFElapsed = performance.now() - requestStartTime + logDebug( + 'FormView', + `[DIAG] requestFromPlugin DISPATCH AFTER RAF: command="${command}", correlationId="${correlationId}", dispatchElapsed=${dispatchAfterRAFElapsed.toFixed(2)}ms`, + ) + dispatch('SEND_TO_PLUGIN', [command, requestData], `WebView: requestFromPlugin: ${String(command)}`) + }) }) }) - }) - .then((result) => { - const elapsed = performance.now() - requestStartTime - logDebug( - 'FormView', - `[DIAG] requestFromPlugin RESOLVED: command="${command}", correlationId="${correlationId}", elapsed=${elapsed.toFixed(2)}ms, pendingRequests=${ - pendingRequestsRef.current.size - }`, - ) - return result - }) - .catch((error) => { - const elapsed = performance.now() - requestStartTime - logDebug( - 'FormView', - `[DIAG] requestFromPlugin REJECTED: command="${command}", correlationId="${correlationId}", elapsed=${elapsed.toFixed(2)}ms, error="${error.message}", pendingRequests=${ - pendingRequestsRef.current.size - }`, - ) - throw error - }) - }, [dispatch, pluginData?.windowId]) // Memoize to prevent infinite loops - only recreate if dispatch or windowId changes + .then((result) => { + const elapsed = performance.now() - requestStartTime + logDebug( + 'FormView', + `[DIAG] requestFromPlugin RESOLVED: command="${command}", correlationId="${correlationId}", elapsed=${elapsed.toFixed(2)}ms, pendingRequests=${ + pendingRequestsRef.current.size + }`, + ) + return result + }) + .catch((error) => { + const elapsed = performance.now() - requestStartTime + logDebug( + 'FormView', + `[DIAG] requestFromPlugin REJECTED: command="${command}", correlationId="${correlationId}", elapsed=${elapsed.toFixed(2)}ms, error="${ + error.message + }", pendingRequests=${pendingRequestsRef.current.size}`, + ) + throw error + }) + }, + [dispatch, pluginData?.windowId], + ) // Memoize to prevent infinite loops - only recreate if dispatch or windowId changes // Load folders on demand when needed (matching FormBuilder pattern) const loadFolders = useCallback(async () => { @@ -300,7 +303,7 @@ export function FormView({ data, dispatch, reactSettings, setReactSettings, onSu useEffect(() => { const customCSS = pluginData?.customCSS || '' if (!customCSS || typeof document === 'undefined') return - + // $FlowFixMe[incompatible-use] - document.head is checked for null const head = document.head if (!head) return @@ -308,18 +311,18 @@ export function FormView({ data, dispatch, reactSettings, setReactSettings, onSu // Create a style element with a unique ID to avoid duplicates const styleId = 'form-custom-css' let styleElement = document.getElementById(styleId) - + if (!styleElement) { styleElement = document.createElement('style') styleElement.id = styleId // $FlowFixMe[incompatible-use] - head is checked for null above head.appendChild(styleElement) } - + if (styleElement) { styleElement.textContent = customCSS } - + // Cleanup: remove style element when component unmounts or CSS changes return () => { const element = document.getElementById(styleId) @@ -576,6 +579,9 @@ export function FormView({ data, dispatch, reactSettings, setReactSettings, onSu notes={notes} requestFromPlugin={requestFromPlugin} windowId={pluginData.windowId} // Pass windowId to DynamicDialog + defaultValues={pluginData?.defaultValues || {}} // Pass default values for form pre-population + templateFilename={pluginData?.templateFilename || ''} // Pass template filename for autosave + templateTitle={pluginData?.templateTitle || ''} // Pass template title for autosave onFoldersChanged={() => { reloadFolders() }} diff --git a/dwertheimer.Forms/src/index.js b/dwertheimer.Forms/src/index.js index 7b1d96dd6..beb1b20b8 100644 --- a/dwertheimer.Forms/src/index.js +++ b/dwertheimer.Forms/src/index.js @@ -15,7 +15,7 @@ // So you need to add a line below for each function that you want NP to have access to. // Typically, listed below are only the top-level plug-in functions listed in plugin.json -export { openTemplateForm, openFormBuilder, testRequestHandlers, openFormBrowser, triggerOpenForm } from './NPTemplateForm' +export { openTemplateForm, openFormBuilder, testRequestHandlers, openFormBrowser, triggerOpenForm, restoreFormFromAutosave } from './NPTemplateForm' export { onFormSubmitFromHTMLView } from './formSubmitRouter' export { onFormBuilderAction } from './formBuilderRouter' export { onFormBrowserAction } from './formBrowserRouter' diff --git a/dwertheimer.Forms/src/requestHandlers.js b/dwertheimer.Forms/src/requestHandlers.js index 140b9b65c..13e230e3b 100644 --- a/dwertheimer.Forms/src/requestHandlers.js +++ b/dwertheimer.Forms/src/requestHandlers.js @@ -19,6 +19,7 @@ import { loadTemplateBodyFromTemplate, loadTemplateRunnerArgsFromTemplate, forma import { logDebug, logError, logInfo, logWarn } from '@helpers/dev' import { getFoldersMatching, getFolderFromFilename } from '@helpers/folders' import { displayTitle } from '@helpers/paragraph' +import { createRunPluginCallbackUrl } from '@helpers/general' import { getAllTeamspaceIDsAndTitles } from '@helpers/NPTeamspace' import { parseTeamspaceFilename } from '@helpers/teamspace' import { showMessage } from '@helpers/userInput' @@ -851,14 +852,36 @@ export async function saveAutosave(params: { filename: string, content: string, if (isTrashFolder) { // For @Trash folder, use projectNoteByTitle with searchAllFolders: true // because projectNotes excludes trash notes + // Search by filename pattern since NotePlan appends numbers to filenames, not titles const potentialNotes = DataStore.projectNoteByTitle(noteTitle, true, true) ?? [] - // Filter to match the exact folder - const matchingNotes = potentialNotes.filter((n) => { + + // First, try to find exact title match + let matchingNotes = potentialNotes.filter((n) => { const noteFolder = getFolderFromFilename(n.filename) - return noteFolder === folder && displayTitle(n) === noteTitle + const noteDisplayTitle = displayTitle(n) + return noteFolder === folder && noteDisplayTitle === noteTitle }) + + // If no exact match, search by filename pattern (NotePlan appends " 2", " 3", etc. to filenames) + if (matchingNotes.length === 0) { + // Extract base filename without extension (e.g., "Autosave-Jeff-Meeting-Form-2025-12-31T08-40-49") + const baseFilename = noteTitle.replace(/\.(md|txt)$/i, '') + matchingNotes = potentialNotes.filter((n) => { + const noteFolder = getFolderFromFilename(n.filename) + if (noteFolder !== folder) return false + + // Get filename without folder and extension + const noteFilename = n.filename.split('/').pop() || '' + const noteFilenameBase = noteFilename.replace(/\.(md|txt)$/i, '').replace(/\s+\d+$/, '') // Remove number suffix + + // Match if base filename matches (ignoring number suffix) + return noteFilenameBase === baseFilename || noteFilenameBase === noteTitle + }) + } + if (matchingNotes.length > 0) { - note = matchingNotes[0] + // Prefer exact title match, otherwise use first match (which should be the original, not numbered) + note = matchingNotes.find((n) => displayTitle(n) === noteTitle) || matchingNotes[0] logDebug(pluginJson, `saveAutosave: Found existing note in trash folder "${folder}": ${note.filename}`) } } else if (folder === '/') { @@ -883,10 +906,45 @@ export async function saveAutosave(params: { filename: string, content: string, } } - // If note not found, create it using getOrMakeRegularNoteInFolder + // If note not found, create it + // For @Trash, we need to use a different approach since getOrMakeRegularNoteInFolder doesn't search trash properly if (!note) { logDebug(pluginJson, `saveAutosave: Note not found, creating new note`) - note = await getOrMakeRegularNoteInFolder(noteTitle, folder) + if (isTrashFolder) { + // For @Trash, create the note directly using DataStore.newNote (synchronous, not async) + const noteFilename = DataStore.newNote(noteTitle, folder) + if (noteFilename) { + // Try to get the note by filename first + note = await DataStore.projectNoteByFilename(noteFilename) + if (!note) { + // Wait a bit for the note to be available in the cache + await new Promise((resolve) => setTimeout(resolve, 100)) + // Try again + note = await DataStore.projectNoteByFilename(noteFilename) + if (!note) { + // If that fails, try to find it using projectNoteByTitle with searchAllFolders + const foundNotes = DataStore.projectNoteByTitle(noteTitle, true, true) ?? [] + const matchingNotes = foundNotes.filter((n) => { + const noteFolder = getFolderFromFilename(n.filename) + return noteFolder === folder && displayTitle(n) === noteTitle + }) + if (matchingNotes.length > 0) { + note = matchingNotes[0] + logDebug(pluginJson, `saveAutosave: Found newly created note in trash: ${note.filename}`) + } + } + } + if (note) { + // Update cache to ensure note is available + DataStore.updateCache(note, true) + logDebug(pluginJson, `saveAutosave: Created/found note in trash: ${note.filename}`) + } + } + } else { + // For other folders, use getOrMakeRegularNoteInFolder + note = await getOrMakeRegularNoteInFolder(noteTitle, folder) + } + if (!note) { logError(pluginJson, `saveAutosave: Failed to get or create note "${noteTitle}" in folder "${folder}"`) return { @@ -895,7 +953,7 @@ export async function saveAutosave(params: { filename: string, content: string, data: null, } } - logDebug(pluginJson, `saveAutosave: Created new note: ${note.filename}`) + logDebug(pluginJson, `saveAutosave: Created/found note: ${note.filename}`) } // Extract JSON content from the code block (content comes as "```json\n{...}\n```") @@ -927,6 +985,48 @@ export async function saveAutosave(params: { filename: string, content: string, } } + // Add xcallback URL outside the codeblock for restoring the form + // Parse the formState to get the form title if available + let formStateObj: any = {} + try { + formStateObj = params.formState || JSON.parse(jsonContent) + } catch (e) { + logDebug(pluginJson, `saveAutosave: Could not parse formState, using empty object`) + } + + // Create xcallback URL to restore the form + // The restore command will need the autosave filename to restore from + const restoreCommand = 'Restore form from autosave' + const restoreUrl = createRunPluginCallbackUrl(pluginJson['plugin.id'], restoreCommand, [filename]) + + // Add the restore link to the note content (outside the codeblock) + // Check if the link already exists in the note + const restoreLinkText = `[Restore form from autosave](${restoreUrl})` + const noteContent = note.content || '' + + // Remove existing restore link if present (look for the pattern) + let updatedContent = noteContent.replace(/\[Restore form from autosave\]\(noteplan:\/\/[^\)]+\)\n?/g, '') + + // Split content into lines for easier manipulation + const lines = updatedContent.split('\n') + + // Find where to insert the restore link + // If note has frontmatter, insert after frontmatter; otherwise insert at index 1 (after title) + const fmEndIndex = endOfFrontmatterLineIndex(note) + let insertIndex = 1 // Default: after title line (index 0) + + if (fmEndIndex !== -1) { + // Has frontmatter, insert after it + insertIndex = fmEndIndex + 1 + } + + // Insert the restore link at the calculated index + lines.splice(insertIndex, 0, restoreLinkText, '') // Add link and a blank line + updatedContent = lines.join('\n') + + // Update the note content + note.content = updatedContent + // Update cache DataStore.updateCache(note, true) diff --git a/dwertheimer.Forms/src/windowManagement.js b/dwertheimer.Forms/src/windowManagement.js index 9918f4740..7e0551413 100644 --- a/dwertheimer.Forms/src/windowManagement.js +++ b/dwertheimer.Forms/src/windowManagement.js @@ -156,6 +156,8 @@ export function createWindowInitData(argObj: Object): PassedData { const ENV_MODE = 'development' /* helps during development. set to 'production' when ready to release */ const formTitle = argObj?.formTitle || '' const templateTitle = argObj?.templateTitle || formTitle || '' + const templateFilename = argObj?.templateFilename || '' + logDebug(pluginJson, `createWindowInitData: templateFilename="${templateFilename}", templateTitle="${templateTitle}", formTitle="${formTitle}"`) // Use the same logic as customId in windowOptions to ensure consistency // This ensures windowId in pluginData matches the actual window customId const windowId = getFormWindowId(argObj?.formTitle || argObj?.windowTitle || '') @@ -173,7 +175,9 @@ export function createWindowInitData(argObj: Object): PassedData { windowId: windowId, // Store window ID in pluginData so we can retrieve it later formTitle: formTitle, // Store form title for window ID reconstruction templateTitle: templateTitle, // Store template title for URL generation + templateFilename: templateFilename, // Store template filename for autosave launchLink: launchLink, // Store launchLink for Form URL button + defaultValues: argObj?.defaultValues || {}, // Store default values for form pre-population }, title: formTitle || REACT_WINDOW_TITLE, width: argObj?.width, @@ -200,9 +204,29 @@ export function getPluginData(argObj: Object): { [string]: mixed } { logDebug(pluginJson, `getPluginData: ENTRY - argObj keys: ${Object.keys(argObj || {}).join(', ')}`) // Check if form fields include folder-chooser or note-chooser - const formFields = argObj.formFields || [] + let formFields = argObj.formFields || [] logDebug(pluginJson, `getPluginData: Checking ${formFields.length} form fields for folder-chooser/note-chooser`) + // Check if autosave is enabled in plugin settings and add autosave field if needed + const autosaveEnabled = DataStore.settings?.autosave === true + const hasAutosaveField = formFields.some((field) => field.type === 'autosave') + + if (autosaveEnabled && !hasAutosaveField) { + logDebug(pluginJson, `getPluginData: Autosave enabled in settings, adding invisible autosave field`) + // Add an invisible autosave field silently + formFields = [ + ...formFields, + { + type: 'autosave', + invisible: true, // Hide the UI but still perform autosaves + autosaveInterval: 2, // Default 2 seconds + // autosaveFilename will use default pattern with form title + }, + ] + // Update argObj with the modified formFields + argObj.formFields = formFields + } + // Log field types for debugging const fieldTypes = formFields.map((f) => f.type).filter(Boolean) logDebug(pluginJson, `getPluginData: Field types found: ${fieldTypes.join(', ')}`) diff --git a/helpers/react/DynamicDialog/AutosaveField.jsx b/helpers/react/DynamicDialog/AutosaveField.jsx index dd496eed6..a273f9faa 100644 --- a/helpers/react/DynamicDialog/AutosaveField.jsx +++ b/helpers/react/DynamicDialog/AutosaveField.jsx @@ -3,7 +3,7 @@ // Autosave field component for DynamicDialog. // Automatically saves form state periodically with debouncing. //-------------------------------------------------------------------------- -import React, { useState, useEffect, useRef, useCallback } from 'react' +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { logDebug, logError } from '@helpers/react/reactDev' type AutosaveFieldProps = { @@ -13,9 +13,12 @@ type AutosaveFieldProps = { autosaveInterval?: number, // Interval in seconds (default: 2) autosaveFilename?: string, // Filename pattern (default: "@Trash/Autosave-") formTitle?: string, // Form title to include in filename + templateFilename?: string, // Template filename for form identification + templateTitle?: string, // Template title for form identification compactDisplay?: boolean, disabled?: boolean, invisible?: boolean, // If true, hide the UI but still perform autosaves + onRegisterTrigger?: (triggerFn: () => Promise) => void, // Callback to register trigger function } /** @@ -93,9 +96,12 @@ const AutosaveField = ({ autosaveInterval = 2, autosaveFilename, formTitle, + templateFilename, + templateTitle, compactDisplay = false, disabled = false, invisible = false, + onRegisterTrigger, }: AutosaveFieldProps): React$Node => { const [lastSaveTime, setLastSaveTime] = useState(null) const [timeAgo, setTimeAgo] = useState('Never saved') @@ -104,8 +110,14 @@ const AutosaveField = ({ const saveTimerRef = useRef(null) const timeAgoTimerRef = useRef(null) const autosaveFilenameRef = useRef(null) // Store filename generated at startup + const updatedSettingsRef = useRef<{ [key: string]: any }>(updatedSettings) // Store latest settings for debounced save const intervalMs = autosaveInterval * 1000 + // Keep ref updated with latest settings + useEffect(() => { + updatedSettingsRef.current = updatedSettings + }, [updatedSettings]) + // Generate filename once at startup, or regenerate if formTitle becomes available useEffect(() => { const pattern = autosaveFilename || '@Trash/Autosave-' @@ -153,60 +165,79 @@ const AutosaveField = ({ } }, []) - // Save function that sends to plugin - const performSave = useCallback(() => { - if (!requestFromPlugin || disabled) { - return - } + // Save function that sends to plugin (reads from ref to avoid dependency on updatedSettings) + const performSave = useCallback( + async (force: boolean = false): Promise => { + if (!requestFromPlugin || disabled) { + return Promise.resolve() + } - const currentState = serializeState(updatedSettings) + // Read latest settings from ref (this avoids dependency on updatedSettings) + const latestSettings = updatedSettingsRef.current + const currentState = serializeState(latestSettings) - // Only save if state has changed - if (currentState === lastSavedStateRef.current) { - logDebug('AutosaveField', 'State unchanged, skipping save') - return - } + // Only save if state has changed (unless forced) + if (!force && currentState === lastSavedStateRef.current) { + logDebug('AutosaveField', 'State unchanged, skipping save') + return Promise.resolve() + } - try { - setIsSaving(true) - const filename = autosaveFilenameRef.current || generateAutosaveFilename(autosaveFilename, formTitle) + try { + setIsSaving(true) + const filename = autosaveFilenameRef.current || generateAutosaveFilename(autosaveFilename, formTitle) - logDebug('AutosaveField', `Saving form state to ${filename}`) + logDebug('AutosaveField', `Saving form state to ${filename}`) - // Add lastUpdated timestamp to form state - const stateWithTimestamp = { - ...updatedSettings, - lastUpdated: new Date().toLocaleString(), // Local timestamp - } + // Add lastUpdated timestamp and form identification to form state + const stateWithTimestamp = { + ...latestSettings, + lastUpdated: new Date().toLocaleString(), // Local timestamp + __formTitle__: formTitle || '', // Form title for restoration + __templateFilename__: templateFilename || '', // Template filename for restoration + __templateTitle__: templateTitle || '', // Template title for restoration + } - // Send to plugin asynchronously (non-blocking) - // Use a code block format as suggested - const formStateCode = `\`\`\`json + // Send to plugin asynchronously + // Use a code block format as suggested + const formStateCode = `\`\`\`json ${JSON.stringify(stateWithTimestamp, null, 2)} \`\`\`` - // Fire and forget - don't await, just send it - requestFromPlugin('saveAutosave', { - filename, - content: formStateCode, - formState: stateWithTimestamp, // Also send as object for easier parsing (with timestamp) - }).catch((error) => { - logError('AutosaveField', `Error saving autosave: ${error.message}`) - }) - - // Update last saved state and time - lastSavedStateRef.current = currentState - const now = new Date() - setLastSaveTime(now) - setTimeAgo('Just now') + // Await the save to ensure it completes before form submission + await requestFromPlugin('saveAutosave', { + filename, + content: formStateCode, + formState: stateWithTimestamp, // Also send as object for easier parsing (with timestamp) + }) + + // Update last saved state and time + lastSavedStateRef.current = currentState + const now = new Date() + setLastSaveTime(now) + setTimeAgo('Just now') + + logDebug('AutosaveField', 'Autosave completed successfully') + } catch (error) { + logError('AutosaveField', `Error in performSave: ${error.message}`) + throw error // Re-throw so caller knows it failed + } finally { + setIsSaving(false) + } + }, + [requestFromPlugin, autosaveFilename, formTitle, templateFilename, templateTitle, serializeState, disabled], + ) - logDebug('AutosaveField', 'Autosave completed successfully') - } catch (error) { - logError('AutosaveField', `Error in performSave: ${error.message}`) - } finally { - setIsSaving(false) + // Create a stable trigger function using useMemo to prevent re-registration on every render + const triggerSave = useMemo(() => { + return () => performSave(true) // Force save even if state unchanged + }, [performSave]) + + // Register trigger function with parent (only when onRegisterTrigger or triggerSave changes) + useEffect(() => { + if (onRegisterTrigger) { + onRegisterTrigger(triggerSave) } - }, [updatedSettings, requestFromPlugin, autosaveFilename, formTitle, serializeState, disabled]) + }, [onRegisterTrigger, triggerSave]) // Update time ago display useEffect(() => { @@ -235,7 +266,7 @@ ${JSON.stringify(stateWithTimestamp, null, 2)} } }, [lastSaveTime]) - // Debounced save effect + // Debounced save effect (only depends on updatedSettings and intervalMs, not performSave) useEffect(() => { // Clear existing timer if (saveTimerRef.current) { @@ -243,8 +274,9 @@ ${JSON.stringify(stateWithTimestamp, null, 2)} } // Set new timer to save after interval + // Use a stable reference to performSave via closure saveTimerRef.current = (window.setTimeout(() => { - performSave() + performSave(false) // Regular autosave, don't force }, intervalMs): any) return () => { @@ -252,7 +284,7 @@ ${JSON.stringify(stateWithTimestamp, null, 2)} clearTimeout((saveTimerRef.current: any)) } } - }, [updatedSettings, intervalMs, performSave]) + }, [updatedSettings, intervalMs]) // Removed performSave from dependencies to prevent timer reset // Cleanup on unmount useEffect(() => { @@ -273,7 +305,6 @@ ${JSON.stringify(stateWithTimestamp, null, 2)} return (
- {label &&
{label}
}
{isSaving ? Saving... : Autosaved {timeAgo}}
diff --git a/helpers/react/DynamicDialog/DynamicDialog.jsx b/helpers/react/DynamicDialog/DynamicDialog.jsx index a12298704..482cf1773 100644 --- a/helpers/react/DynamicDialog/DynamicDialog.jsx +++ b/helpers/react/DynamicDialog/DynamicDialog.jsx @@ -20,7 +20,7 @@ // Imports //-------------------------------------------------------------------------- -import React, { useEffect, useRef, useState, type ElementRef } from 'react' +import React, { useEffect, useRef, useState, useCallback, type ElementRef } from 'react' import { renderItem } from './dialogElementRenderer' import './DynamicDialog.css' // Import the CSS file import Modal from '@helpers/react/Modal' @@ -181,6 +181,9 @@ export type TDynamicDialogProps = { onNotesChanged?: () => void, // Callback to reload notes after creating a new note windowId?: string, // Optional window ID to pass when submitting (for backward compatibility, will use fallback if not provided) keepOpenOnSubmit?: boolean, // If true, don't close the window after submit (e.g., for Form Browser context) + defaultValues?: { [key: string]: any }, // Default values to pre-populate form fields + templateFilename?: string, // Template filename for autosave field + templateTitle?: string, // Template title for autosave field } //-------------------------------------------------------------------------- @@ -214,6 +217,9 @@ const DynamicDialog = ({ onFoldersChanged, onNotesChanged, keepOpenOnSubmit = false, // Default to false (close on submit for backward compatibility) + defaultValues, + templateFilename, + templateTitle, }: TDynamicDialogProps): React$Node => { if (!isOpen) return null const items = passedItems || [] @@ -222,11 +228,14 @@ const DynamicDialog = ({ // HELPER FUNCTIONS //---------------------------------------------------------------------- - function getInitialItemStateObject(items: Array): { [key: string]: any } { - const initialItemValues = {} + function getInitialItemStateObject(items: Array, defaultValues?: { [key: string]: any }): { [key: string]: any } { + const initialItemValues: { [key: string]: any } = {} items.forEach((item) => { // $FlowFixMe[prop-missing] - if (item.key) initialItemValues[item.key] = item.value ?? item.checked ?? item.default ?? '' + if (item.key) { + // Use defaultValues if provided, otherwise fall back to item.value, item.checked, item.default, or empty string + initialItemValues[item.key] = defaultValues?.[item.key] ?? item.value ?? item.checked ?? item.default ?? '' + } }) return initialItemValues } @@ -268,8 +277,10 @@ const DynamicDialog = ({ const dialogRef = useRef>(null) const dropdownRef = useRef(null) const [changesMade, setChangesMadeInternal] = useState(false) - const [updatedSettings, setUpdatedSettings] = useState(getInitialItemStateObject(items)) + const [updatedSettings, setUpdatedSettings] = useState(getInitialItemStateObject(items, defaultValues)) const updatedSettingsRef = useRef(updatedSettings) + const autosaveTriggersRef = useRef Promise>>([]) // Store autosave trigger functions + const isSavingRef = useRef(false) // Guard to prevent multiple simultaneous saves useEffect(() => { updatedSettingsRef.current = updatedSettings @@ -337,14 +348,48 @@ const DynamicDialog = ({ }) } - const handleSave = () => { - if (onSave) { - // Pass keepOpenOnSubmit flag in windowId as a special marker, or pass it separately - // For now, we'll pass it as part of a special windowId format, or the caller can check the prop - // Actually, the caller (onSave) can access keepOpenOnSubmit via closure, so we don't need to pass it - onSave(updatedSettingsRef.current, windowId) // Pass windowId if available, otherwise use fallback pattern in plugin + // Stable callback for registering autosave triggers (created once, not recreated on every render) + const registerAutosaveTrigger = useCallback((triggerFn: () => Promise) => { + // Register autosave trigger function + if (!autosaveTriggersRef.current.includes(triggerFn)) { + autosaveTriggersRef.current.push(triggerFn) + logDebug('DynamicDialog', `Registered autosave trigger (total: ${autosaveTriggersRef.current.length})`) + } + }, []) + + const handleSave = async () => { + // Guard against multiple simultaneous saves + if (isSavingRef.current) { + logDebug('DynamicDialog', 'Save already in progress, skipping duplicate save') + return + } + + try { + isSavingRef.current = true + + // Trigger final autosave before form submission if there are any autosave fields + if (autosaveTriggersRef.current.length > 0) { + logDebug('DynamicDialog', `Triggering final autosave before form submission (${autosaveTriggersRef.current.length} autosave field(s))`) + try { + // Trigger all autosave fields and wait for them to complete + await Promise.all(autosaveTriggersRef.current.map((trigger) => trigger())) + logDebug('DynamicDialog', 'Final autosave completed before form submission') + } catch (error) { + logError('DynamicDialog', `Error during final autosave: ${error.message}`) + // Continue with form submission even if autosave fails + } + } + + if (onSave) { + // Pass keepOpenOnSubmit flag in windowId as a special marker, or pass it separately + // For now, we'll pass it as part of a special windowId format, or the caller can check the prop + // Actually, the caller (onSave) can access keepOpenOnSubmit via closure, so we don't need to pass it + onSave(updatedSettingsRef.current, windowId) // Pass windowId if available, otherwise use fallback pattern in plugin + } + logDebug('Dashboard', `DynamicDialog saved updates`, { updatedSettings: updatedSettingsRef.current, windowId, keepOpenOnSubmit }) + } finally { + isSavingRef.current = false } - logDebug('Dashboard', `DynamicDialog saved updates`, { updatedSettings: updatedSettingsRef.current, windowId, keepOpenOnSubmit }) } const handleDropdownOpen = () => { @@ -474,6 +519,9 @@ const DynamicDialog = ({ onFoldersChanged, // Pass onFoldersChanged to reload folders after creating a new folder onNotesChanged, // Pass onNotesChanged to reload notes after creating a new note formTitle: title || windowTitle, // Pass form title (or windowTitle as fallback) for autosave field + templateFilename: templateFilename, // Pass template filename for autosave field + templateTitle: templateTitle, // Pass template title for autosave field + onRegisterAutosaveTrigger: item.type === 'autosave' ? registerAutosaveTrigger : undefined, } if (item.type === 'combo' || item.type === 'dropdown-select') { renderItemProps.inputRef = dropdownRef diff --git a/helpers/react/DynamicDialog/dialogElementRenderer.js b/helpers/react/DynamicDialog/dialogElementRenderer.js index 05d78c024..fc039d973 100644 --- a/helpers/react/DynamicDialog/dialogElementRenderer.js +++ b/helpers/react/DynamicDialog/dialogElementRenderer.js @@ -59,6 +59,9 @@ type RenderItemProps = { onFoldersChanged?: () => void, // Callback to reload folders after creating a new folder onNotesChanged?: () => void, // Callback to reload notes after creating a new note formTitle?: string, // Form title for autosave field + templateFilename?: string, // Template filename for autosave field + templateTitle?: string, // Template title for autosave field + onRegisterAutosaveTrigger?: (triggerFn: () => Promise) => void, // Register autosave trigger function } /** @@ -91,6 +94,9 @@ export function renderItem({ onFoldersChanged, // Callback to reload folders after creating a new folder onNotesChanged, // Callback to reload notes after creating a new note formTitle, // Form title for autosave field + templateFilename, // Template filename for autosave field + templateTitle, // Template title for autosave field + onRegisterAutosaveTrigger, // Register autosave trigger function }: RenderItemProps): React$Node { const element = () => { const thisLabel = item.label || '?' @@ -795,6 +801,8 @@ export function renderItem({ const autosaveInterval = item.autosaveInterval ?? 2 const autosaveFilename = item.autosaveFilename const invisible = (item: any).invisible || false + const templateFilename = (item: any).templateFilename + const templateTitle = (item: any).templateTitle return (
@@ -806,9 +814,12 @@ export function renderItem({ autosaveInterval={autosaveInterval} autosaveFilename={autosaveFilename} formTitle={formTitle} + templateFilename={templateFilename} + templateTitle={templateTitle} compactDisplay={compactDisplay} disabled={disabled} invisible={invisible} + onRegisterTrigger={onRegisterAutosaveTrigger} />
) From 6d6303bd40a05c6575ded404c1b50697c6ff2c46 Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Thu, 1 Jan 2026 16:47:54 -0800 Subject: [PATCH 16/31] Adding restore --- dwertheimer.Forms/src/requestHandlers.js | 30 ++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/dwertheimer.Forms/src/requestHandlers.js b/dwertheimer.Forms/src/requestHandlers.js index 13e230e3b..0babfb8ee 100644 --- a/dwertheimer.Forms/src/requestHandlers.js +++ b/dwertheimer.Forms/src/requestHandlers.js @@ -1004,8 +1004,9 @@ export async function saveAutosave(params: { filename: string, content: string, const restoreLinkText = `[Restore form from autosave](${restoreUrl})` const noteContent = note.content || '' - // Remove existing restore link if present (look for the pattern) - let updatedContent = noteContent.replace(/\[Restore form from autosave\]\(noteplan:\/\/[^\)]+\)\n?/g, '') + // Remove existing restore link if present (look for the pattern, including any trailing newline and blank lines) + // Remove the link and up to 2 following newlines (to clean up extra blank lines) + let updatedContent = noteContent.replace(/\[Restore form from autosave\]\(noteplan:\/\/[^\)]+\)\n{0,2}/g, '') // Split content into lines for easier manipulation const lines = updatedContent.split('\n') @@ -1020,8 +1021,29 @@ export async function saveAutosave(params: { filename: string, content: string, insertIndex = fmEndIndex + 1 } - // Insert the restore link at the calculated index - lines.splice(insertIndex, 0, restoreLinkText, '') // Add link and a blank line + // Check if restore link already exists (shouldn't happen after removal, but be safe) + const existingLinkIndex = lines.findIndex((line) => line.includes('[Restore form from autosave]')) + if (existingLinkIndex === -1) { + // Only insert if it doesn't already exist + // Check what's at the insert position and after to avoid adding extra blank lines + const lineAtInsert = lines[insertIndex] || '' + const lineAfterInsert = lines[insertIndex + 1] || '' + + // If the line at insert position is already blank, just insert the link + if (lineAtInsert.trim() === '') { + lines[insertIndex] = restoreLinkText + } else { + // Insert link and ensure exactly one blank line after it + lines.splice(insertIndex, 0, restoreLinkText) + // If the next line isn't blank, add one blank line + if (insertIndex + 1 >= lines.length || lines[insertIndex + 1].trim() !== '') { + lines.splice(insertIndex + 1, 0, '') + } + } + } else { + // Link exists, just update it (don't add extra blank lines) + lines[existingLinkIndex] = restoreLinkText + } updatedContent = lines.join('\n') // Update the note content From ebadb99718f8fe777eac0bb71fd67e46b54deb7f Mon Sep 17 00:00:00 2001 From: David Wertheimer Date: Thu, 1 Jan 2026 20:48:38 -0800 Subject: [PATCH 17/31] Replace react-day-picker with HTML date input and standardize DynamicDialog input widths - Replace CalendarPicker (react-day-picker) with GenericDatePicker using native HTML date input - Standardize all input field widths using --dynamic-dialog-input-width CSS variable (180px default) - Fix validation messages to appear inline to the right of fields with triangle icon - Add transparent placeholder divs to reserve space for validation messages and prevent input shrinking - Fix chooser alignment and add consistent 1rem gap in compact mode - Update FormBuilder documentation with CSS override instructions - Fix Safari calendar icon visibility in date picker - Remove unused CalendarPicker options (buttonText, showCalendarByDefault) from Form Builder editor --- .../src/components/FieldEditor.jsx | 28 ---- .../src/components/FormSettings.jsx | 8 ++ .../DynamicDialog/DropdownSelectChooser.css | 25 +++- helpers/react/DynamicDialog/DynamicDialog.css | 118 +++++++++++++++-- .../DynamicDialog/ExpandableTextarea.css | 34 ++++- .../DynamicDialog/ExpandableTextarea.jsx | 51 +++++--- helpers/react/DynamicDialog/FolderChooser.css | 9 +- .../react/DynamicDialog/GenericDatePicker.css | 101 ++++++++++++++ .../react/DynamicDialog/GenericDatePicker.jsx | 123 ++++++++++++++++++ .../react/DynamicDialog/HeadingChooser.css | 12 +- helpers/react/DynamicDialog/InputBox.jsx | 57 ++++---- .../DynamicDialog/MultiSelectChooser.css | 21 +++ .../react/DynamicDialog/SearchableChooser.css | 23 +++- .../react/DynamicDialog/SearchableChooser.jsx | 10 +- .../DynamicDialog/dialogElementRenderer.js | 32 ++--- 15 files changed, 534 insertions(+), 118 deletions(-) create mode 100644 helpers/react/DynamicDialog/GenericDatePicker.css create mode 100644 helpers/react/DynamicDialog/GenericDatePicker.jsx diff --git a/dwertheimer.Forms/src/components/FieldEditor.jsx b/dwertheimer.Forms/src/components/FieldEditor.jsx index fe9971ab8..a23fdb466 100644 --- a/dwertheimer.Forms/src/components/FieldEditor.jsx +++ b/dwertheimer.Forms/src/components/FieldEditor.jsx @@ -407,34 +407,6 @@ export function FieldEditor({ field, allFields, onSave, onCancel, requestFromPlu {editedField.type === 'calendarpicker' && ( <> -
- - { - const updated = { ...editedField } - ;(updated: any).buttonText = e.target.value - setEditedField(updated) - }} - placeholder="Button text" - /> -
Text to show on the button which pops up the calendar picker
-
-
- -
{editedField.type !== 'separator' && editedField.type !== 'heading' && (
diff --git a/helpers/react/DynamicDialog/DropdownSelectChooser.css b/helpers/react/DynamicDialog/DropdownSelectChooser.css index 93f3ff2a6..149c73b89 100644 --- a/helpers/react/DynamicDialog/DropdownSelectChooser.css +++ b/helpers/react/DynamicDialog/DropdownSelectChooser.css @@ -1,7 +1,17 @@ /* DropdownSelectChooser Component Styles */ .dropdown-select-chooser-container { - width: 100%; + box-sizing: border-box; + /* Don't set width here - let searchable-chooser-base handle it */ + width: auto; /* Let it size based on content */ + max-width: none; /* Don't constrain width */ +} + +/* When the inner chooser is in compact mode, ensure container doesn't interfere */ +.dropdown-select-chooser-container .searchable-chooser-base.compact { + width: auto; + max-width: none; + gap: 1rem; /* Match input-box-container-compact gap */ } .dropdown-select-chooser-container .dropdown-select-chooser-input { @@ -52,6 +62,19 @@ /* Ensure arrow is positioned inside the input wrapper */ .dropdown-select-chooser-input-wrapper { position: relative; + width: var(--dynamic-dialog-input-width, 180px); /* Match standardized input width */ + max-width: var(--dynamic-dialog-input-width, 180px); /* Prevent expansion */ + margin-left: 0; /* Align with other inputs in compact mode */ + flex: 0 0 auto; /* Don't flex, use fixed width */ + box-sizing: border-box; +} + +/* In compact mode, ensure proper alignment */ +.searchable-chooser-base.compact .dropdown-select-chooser-input-wrapper, +.dropdown-select-chooser-container.compact .dropdown-select-chooser-input-wrapper { + width: var(--dynamic-dialog-input-width, 180px); + max-width: var(--dynamic-dialog-input-width, 180px); + margin-left: 0; /* Match input-box-wrapper alignment */ } .dropdown-select-chooser-arrow { diff --git a/helpers/react/DynamicDialog/DynamicDialog.css b/helpers/react/DynamicDialog/DynamicDialog.css index 1be879f61..0d274d98c 100644 --- a/helpers/react/DynamicDialog/DynamicDialog.css +++ b/helpers/react/DynamicDialog/DynamicDialog.css @@ -71,6 +71,7 @@ min-height: 2.5rem; border-bottom: 0.5px solid var(--divider-color, #CDCFD0); padding: 0.5rem 0.75rem; + padding-top: 0.8rem; border-radius: 8px 8px 0 0; width: 100%; box-sizing: border-box; @@ -247,10 +248,12 @@ white-space: nowrap; } - /* Ensure all compact display fields align vertically - use consistent label width */ + /* Standardized input width system - use CSS variables for consistency */ .dynamic-dialog-content { /* Create a CSS variable for compact label width to ensure alignment */ --compact-label-width: auto; + /* Standard input field width - can be overridden in form-specific CSS */ + --dynamic-dialog-input-width: 180px; } /* Unified field label styling for all DynamicDialog fields */ @@ -271,10 +274,62 @@ padding-right: 1rem; } - /* Align validation messages with labels in compact mode */ - .input-box-container-compact + .validation-message { - margin-left: 8rem; - padding-left: 1rem; + /* Validation messages - show to the right of the field with triangle icon */ + .validation-message { + display: inline-flex; + align-items: center; + gap: 0.25rem; + color: var(--tint-color, #dc8a78); + font-size: 0.85rem; + margin-left: 0.5rem; + white-space: nowrap; + vertical-align: middle; + flex-shrink: 0; + width: 6rem; /* Fixed width to prevent input shrinking */ + } + + /* Transparent placeholder to reserve space when no validation error */ + .validation-message-placeholder { + visibility: hidden; + opacity: 0; + width: 6rem; /* Fixed width to match validation message */ + flex-shrink: 0; + } + + /* Triangle icon before validation message */ + .validation-message:not(.validation-message-placeholder) i { + font-size: 0.75rem; + } + + /* Validation message inside input-box-wrapper - shows to the right of input */ + .input-box-wrapper .validation-message { + margin-left: 0.5rem; + margin-top: 0; + flex-shrink: 0; + } + + /* Validation messages for choosers - positioned outside input wrapper to not constrain dropdown */ + .searchable-chooser-base [class*="-chooser-input-wrapper"], + [class*="-chooser-input-wrapper"] { + display: flex; + align-items: center; + position: relative; + flex-shrink: 0; /* Don't let input wrapper shrink */ + } + + /* Validation message after chooser input wrapper */ + .searchable-chooser-base .validation-message, + [class*="-chooser-container"] .validation-message { + margin-left: 0.5rem; + margin-top: 0; + flex-shrink: 0; + position: static; /* Inline, not absolute */ + } + + /* Ensure chooser container is flex so validation message can sit next to input wrapper */ + .searchable-chooser-base.compact { + display: inline-flex; + align-items: center; } /* Align item descriptions with labels in compact mode */ @@ -292,8 +347,32 @@ /* Style for input-box-wrapper - TODO: remove later */ .input-box-wrapper { display: flex; - align-items: end; - /* gap: 10px; */ + align-items: center; /* Changed from 'end' to 'center' for better validation message alignment */ + } + + /* Ensure input doesn't shrink when validation message appears - set fixed width on input */ + .input-box-wrapper .input-box-input { + flex-shrink: 0 !important; /* Don't allow input to shrink */ + } + + .input-box-container-compact .input-box-wrapper .input-box-input { + flex: 0 0 var(--dynamic-dialog-input-width, 180px); /* Fixed width - don't grow or shrink */ + min-width: var(--dynamic-dialog-input-width, 180px); + max-width: var(--dynamic-dialog-input-width, 180px); + } + + /* Standardized width for ThemedSelect (combo) in compact mode */ + .input-box-container-compact .input-box-wrapper { + width: auto; + flex: 0 0 auto; + margin-left: 0; + } + + /* ThemedSelect react-select container width */ + .input-box-container-compact .input-box-wrapper > div { + width: var(--dynamic-dialog-input-width, 180px) !important; + max-width: var(--dynamic-dialog-input-width, 180px) !important; + min-width: var(--dynamic-dialog-input-width, 180px) !important; } /* Style for input-box-label */ @@ -306,7 +385,8 @@ /* Style for input-box-input */ .input-box-input { - flex: 1; + width: var(--dynamic-dialog-input-width, 180px); /* Standardized width */ + max-width: var(--dynamic-dialog-input-width, 180px); /* Prevent expansion */ padding: 4px 8px; /* border: 1px solid #ddd; */ border: 0.5px solid rgb(from var(--fg-main-color) r g b / 0.3); @@ -316,7 +396,24 @@ font-size: 0.85rem; /* vertical spacing above and below */ margin: 0.3rem 0rem; - margin-left: 10px + margin-left: 10px; + box-sizing: border-box; + } + + /* In compact mode, inputs should fill available space after label */ + .input-box-container-compact .input-box-input { + width: var(--dynamic-dialog-input-width, 180px); + max-width: var(--dynamic-dialog-input-width, 180px); + min-width: var(--dynamic-dialog-input-width, 180px); /* Prevent shrinking */ + margin-left: 0; + flex-shrink: 0; /* Don't allow input to shrink */ + } + + /* Input wrapper in compact mode should respect the width */ + .input-box-container-compact .input-box-wrapper { + width: var(--dynamic-dialog-input-width, 180px); + max-width: var(--dynamic-dialog-input-width, 180px); + flex: 0 0 auto; /* Don't flex, use fixed width */ } /* Apply styles for read-only input fields */ .input-box-input:read-only { @@ -413,8 +510,11 @@ } /* Make number boxes a little narrower */ + /* Number inputs can be narrower than text inputs */ .input-box-input-number { width: 6rem; + max-width: 6rem; + /* Number inputs don't need the full standardized width */ } /* Style for input box with invalid input */ diff --git a/helpers/react/DynamicDialog/ExpandableTextarea.css b/helpers/react/DynamicDialog/ExpandableTextarea.css index 0d8261057..31e256e00 100644 --- a/helpers/react/DynamicDialog/ExpandableTextarea.css +++ b/helpers/react/DynamicDialog/ExpandableTextarea.css @@ -6,9 +6,22 @@ } .expandable-textarea-container.compact { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 1rem; /* Match input-box-container-compact gap */ margin-bottom: 0.5rem; } +.expandable-textarea-container.compact .expandable-textarea-label { + min-width: 8rem; + text-align: right; + padding-right: 1rem; + margin-bottom: 0; + align-self: flex-start; + padding-top: 0.5rem; /* Align with textarea top padding */ +} + .expandable-textarea-label { display: block; font-weight: 600; /* Match input-box-label for consistency */ @@ -17,7 +30,8 @@ } .expandable-textarea { - width: 100%; + width: var(--dynamic-dialog-input-width, 180px); /* Standardized width */ + max-width: var(--dynamic-dialog-input-width, 180px); /* Prevent expansion */ padding: 0.5rem; border: 1px solid var(--border-color, #ddd); border-radius: 4px; @@ -28,6 +42,24 @@ transition: height 0.1s ease; background-color: var(--bg-color, #fff); color: var(--text-color, #333); + box-sizing: border-box; + flex: 0 0 auto; /* Don't flex, use fixed width */ +} + +.expandable-textarea-wrapper { + display: flex; + align-items: flex-start; +} + +.expandable-textarea-container.compact .expandable-textarea-wrapper { + display: flex; + align-items: flex-start; +} + +.expandable-textarea-container.compact .expandable-textarea { + width: var(--dynamic-dialog-input-width, 180px); + max-width: var(--dynamic-dialog-input-width, 180px); + margin-left: 0; } .expandable-textarea:focus { diff --git a/helpers/react/DynamicDialog/ExpandableTextarea.jsx b/helpers/react/DynamicDialog/ExpandableTextarea.jsx index 932c9affb..917845f62 100644 --- a/helpers/react/DynamicDialog/ExpandableTextarea.jsx +++ b/helpers/react/DynamicDialog/ExpandableTextarea.jsx @@ -125,29 +125,42 @@ export function ExpandableTextarea({ } } + // Validate if required and empty + const validationError = required && textareaValue.trim() === '' ? 'required' : null + return (
{label && } -