diff --git a/.cursor/rules/noteplan-programming-general.mdc b/.cursor/rules/noteplan-programming-general.mdc index e6451c7af..f120e4270 100644 --- a/.cursor/rules/noteplan-programming-general.mdc +++ b/.cursor/rules/noteplan-programming-general.mdc @@ -274,6 +274,7 @@ See `@helpers/react/DynamicDialog/DynamicDialog.jsx` and `dialogElementRenderer. ## NotePlan Theme Colors When using CSS variables for styling, use these NotePlan theme color variables with their default values: +**DO NOT INVENT NEW CSS VARIABLES. USE THESE EXISTING ONES.** ```css --bg-main-color: #eff1f5; @@ -298,10 +299,16 @@ When using CSS variables for styling, use these NotePlan theme color variables w --hashtag-color: inherit; --attag-color: inherit; --code-color: #0091f8; +--fg-placeholder-color: rgba(76, 79, 105, 0.7); +--fg-error-color: #b85450; +--bg-error-color: #f5e6e6; +--fg-disabled-color: #999999; +--bg-disabled-color: #f5f5f5; ``` **Always use these CSS variables with their default fallback values** when styling React components. For example: ```css -background: var(--tint-color, #dc8a78); +background: var(--bg-main-color, #eff1f5); color: var(--fg-main-color, #4c4f69); +icon: var(--tint-color, #1e66f5); ``` \ No newline at end of file diff --git a/dwertheimer.Favorites/src/components/FavoritesView.jsx b/dwertheimer.Favorites/src/components/FavoritesView.jsx index e11b43d32..14c3c1c47 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 - }) - .catch((error) => { - logError('FavoritesView', `requestFromPlugin REJECTED: command="${command}", correlationId="${correlationId}", error="${error.message}"`) - throw error + dispatch('SEND_TO_PLUGIN', [command, requestData], `FavoritesView: requestFromPlugin: ${String(command)}`) }) - }, [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,32 +254,32 @@ function FavoritesViewComponent({ }, [requestFromPlugin]) // Handle adding favorite note dialog - const handleAddNoteDialogSave = useCallback((updatedSettings: { [key: string]: any }) => { - ;(async () => { + const handleAddNoteDialogSave = useCallback( + async (updatedSettings: { [key: string]: any }) => { 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( @@ -285,15 +293,15 @@ function FavoritesViewComponent({ } return false }, - { maxWaitMs: 3000, checkIntervalMs: 150 } + { 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 { @@ -308,8 +316,9 @@ function FavoritesViewComponent({ timeout: 3000, }) } - })() - }, [requestFromPlugin, loadFavoriteNotes, dispatch, showNotes, favoriteNotes]) + }, + [requestFromPlugin, loadFavoriteNotes, dispatch, showNotes, favoriteNotes], + ) const handleAddNoteDialogCancel = useCallback(() => { setShowAddNoteDialog(false) @@ -325,8 +334,8 @@ function FavoritesViewComponent({ }, [projectNotes, loadProjectNotes]) // Handle adding favorite command dialog - const handleAddCommandDialogSave = useCallback((updatedSettings: { [key: string]: any }) => { - ;(async () => { + const handleAddCommandDialogSave = useCallback( + async (updatedSettings: { [key: string]: any }) => { try { if (updatedSettings.preset && updatedSettings.commandName && updatedSettings.url) { const response = await requestFromPlugin('addFavoriteCommand', { @@ -346,17 +355,18 @@ function FavoritesViewComponent({ } 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 () => { + const handleAddCommandButtonClick = useCallback( + async (key: string, value: string) => { + if (key === 'getCallbackURL') { try { const urlResponse = await requestFromPlugin('getCallbackURL', {}) if (urlResponse && urlResponse.success && urlResponse.url) { @@ -367,10 +377,11 @@ function FavoritesViewComponent({ } 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 +399,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,10 +446,10 @@ function FavoritesViewComponent({ }, [showNotes, favoriteNotes, favoriteCommands]) // Handle removing favorite note - const handleRemoveFavorite = useCallback(async (filename: string) => { - try { - const response = await requestFromPlugin('removeFavoriteNote', { filename }) - if (response && response.success) { + const handleRemoveFavorite = useCallback( + async (filename: string) => { + try { + await requestFromPlugin('removeFavoriteNote', { filename }) // Show toast notification dispatch('SHOW_TOAST', { type: 'SUCCESS', @@ -436,64 +458,119 @@ function FavoritesViewComponent({ }) // Reload the favorites list await loadFavoriteNotes() - } else { - logError('FavoritesView', `Failed to remove favorite note: ${response?.message || 'Unknown error'}`) + } catch (error) { + logError('FavoritesView', `Error removing favorite note: ${error.message}`) dispatch('SHOW_TOAST', { type: 'ERROR', - msg: `Failed to remove favorite: ${response?.message || 'Unknown error'}`, + msg: `Error removing favorite: ${error.message}`, timeout: 3000, }) } - } 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]) + }, + [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(() => { + // 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 + } - return ( -
- -
-
{displayTitle}
- {folder && folder !== '/' && ( -
{folderDisplay}
- )} + // Scroll list to top + if (listRef.current) { + const firstItem = listRef.current.querySelector('[data-index="0"]') + if (firstItem instanceof HTMLElement) { + // 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 with offset + const scrollableParent = listRef.current.parentElement?.parentElement + if (scrollableParent instanceof HTMLElement && scrollableParent.scrollTop !== undefined) { + scrollableParent.scrollTop = scrollOffset + } + } + } + // Focus the filter input + if (filterInputRef.current) { + filterInputRef.current.focus() + } + }, 0) + }, []) + + // 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 => { @@ -504,9 +581,7 @@ function FavoritesViewComponent({
{command.name}
- {command.description && ( -
{command.description}
- )} + {command.description &&
{command.description}
}
) @@ -535,104 +610,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 (
@@ -681,6 +769,7 @@ function FavoritesViewComponent({
+ @@ -716,6 +806,8 @@ function FavoritesViewComponent({ includeRelativeNotes: false, includeTeamspaceNotes: true, required: true, + shortDescriptionOnLine2: true, + showTitleOnly: true, }, { type: 'markdown-preview', @@ -785,13 +877,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()) @@ -803,32 +889,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(() => { @@ -866,18 +955,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.Favorites/src/windowManagement.js b/dwertheimer.Favorites/src/windowManagement.js index b1648e712..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,8 +151,9 @@ export async function openFavoritesBrowser(_isFloating: boolean | string = false // Options for showInMainWindow (main window mode) splitView: false, icon: 'star', - iconColor: 'blue-500', + iconColor: iconColorHex ? iconColorHex : 'blue-500', autoTopPadding: true, + showReloadButton: false, } // Choose the appropriate command based on whether it's floating or main window diff --git a/dwertheimer.Forms/plugin.json b/dwertheimer.Forms/plugin.json index 118fdcf3a..aea9324a4 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", @@ -50,7 +51,7 @@ ] }, { - "name": "Form Browser", + "name": "Form Browser - open in NotePlan Editor", "alias": [ "browser", "formbrowser" @@ -65,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": [ @@ -141,7 +152,7 @@ "jsFunction": "testRequestHandlers" }, { - "name": "All Form Fields Render Test", + "name": "DynamicDialog: All Form Fields Render Test", "alias": [ "testfields", "fieldtest" @@ -165,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/FormFieldRenderTest.js b/dwertheimer.Forms/src/FormFieldRenderTest.js index 9013bef4e..ed5abb333 100644 --- a/dwertheimer.Forms/src/FormFieldRenderTest.js +++ b/dwertheimer.Forms/src/FormFieldRenderTest.js @@ -107,6 +107,62 @@ export async function testFormFieldRender(): Promise { type: 'separator', label: 'Chooser Fields', }, + { + type: 'space-chooser', + label: 'Space Chooser', + key: 'testSpace', + showValue: true, // Show the selected value for debugging + description: 'Select a Space (Private or Teamspace). This is used to filter folders below.', + }, + { + type: 'separator', + label: 'Custom Width Examples', + }, + { + type: 'folder-chooser', + label: 'Folder Chooser (Compact, Custom Width: 80vw)', + key: 'testFolderCompact80vw', + compactDisplay: true, + width: '80vw', + showValue: true, + description: 'Compact display with custom width of 80vw. Width overrides default even in compact mode.', + }, + { + type: 'note-chooser', + label: 'Note Chooser (Non-Compact, Custom Width: 79%)', + key: 'testNote79Percent', + compactDisplay: false, + width: '79%', + showValue: true, + description: 'Non-compact display with custom width of 79%. Width applies to the input field.', + }, + { + type: 'dropdown-select', + label: 'Dropdown (Compact, Custom Width: 300px)', + key: 'testDropdown300px', + compactDisplay: true, + width: '300px', + options: ['Option 1', 'Option 2', 'Option 3', 'Option 4', 'Option 5'], + showValue: true, + description: 'Compact display with custom width of 300px. Width overrides default even in compact mode.', + }, + { + type: 'space-chooser', + label: 'Space Chooser (Non-Compact, Custom Width: calc(100% - 40px))', + key: 'testSpaceCalc', + compactDisplay: false, + width: 'calc(100% - 40px)', + showValue: true, + description: 'Non-compact display with custom width using calc(). Demonstrates advanced CSS width values.', + }, + { + type: 'space-chooser', + label: 'Space Chooser (With All Option)', + key: 'testSpaceWithAll', + includeAllOption: true, + showValue: true, + description: 'Space chooser with "All Private + Spaces" option that returns "__all__"', + }, { type: 'folder-chooser', label: 'Folder Chooser', @@ -116,6 +172,26 @@ export async function testFormFieldRender(): Promise { showValue: true, // Show the selected value for debugging description: 'Loads folders dynamically when form opens', }, + { + type: 'separator', + label: 'Folder Chooser with Space Dependency', + }, + { + type: 'space-chooser', + label: 'Space Chooser (for Folder Dependency)', + key: 'testSpaceForFolder', + showValue: true, + description: 'Select a space to filter folders in the chooser below', + }, + { + type: 'folder-chooser', + label: 'Folder Chooser (Depends on Space)', + key: 'testFolderDependsOnSpace', + sourceSpaceKey: 'testSpaceForFolder', + includeNewFolderOption: true, + showValue: true, + description: 'This folder chooser filters folders by the space selected above. Select a space first, then this will show only folders from that space.', + }, { type: 'note-chooser', label: 'Note Chooser (Default)', @@ -200,7 +276,7 @@ export async function testFormFieldRender(): Promise { { type: 'folder-chooser', label: 'Folder Chooser (for Note Dependency)', - key: 'testFolder', + key: 'testFolderForNote', includeNewFolderOption: true, showValue: true, description: 'Select a folder to filter notes in the chooser below', @@ -209,7 +285,7 @@ export async function testFormFieldRender(): Promise { type: 'note-chooser', label: 'Note Chooser (Depends on Folder)', key: 'testNoteDependsOnFolder', - dependsOnFolderKey: 'testFolder', + sourceFolderKey: 'testFolderForNote', includePersonalNotes: true, includeCalendarNotes: false, includeRelativeNotes: false, @@ -249,7 +325,7 @@ export async function testFormFieldRender(): Promise { type: 'note-chooser', label: 'Note Chooser (Depends on Folder + Can Create)', key: 'testNoteDependsOnFolderWithCreate', - dependsOnFolderKey: 'testFolderForCreate', + sourceFolderKey: 'testFolderForCreate', includeNewNoteOption: true, includePersonalNotes: true, includeCalendarNotes: false, @@ -272,7 +348,7 @@ export async function testFormFieldRender(): Promise { type: 'heading-chooser', label: 'Heading Chooser (Dynamic)', key: 'testHeadingDynamic', - dependsOnNoteKey: 'testNote', + sourceNoteKey: 'testNote', defaultHeading: 'Tasks', optionAddTopAndBottom: true, showValue: true, // Show the selected value for debugging @@ -291,6 +367,58 @@ export async function testFormFieldRender(): Promise { numberOfMonths: 1, description: 'Date picker calendar field', }, + { + type: 'calendarpicker', + label: 'Calendar Picker (Multiple Months)', + key: 'testCalendarMultiMonth', + selectedDate: new Date(), + numberOfMonths: 3, + size: 0.8, + description: 'Date picker showing 3 months at a time, scaled to 80%', + }, + { + type: 'separator', + label: 'Event Chooser', + }, + { + type: 'event-chooser', + label: 'Event Chooser (Today)', + key: 'testEventToday', + eventDate: new Date(), + showValue: true, + description: 'Event chooser for today\'s date (default)', + }, + { + type: 'event-chooser', + label: 'Event Chooser (With Filters)', + key: 'testEventFiltered', + eventDate: new Date(), + allCalendars: true, + includeReminders: true, + showValue: true, + description: 'Event chooser with all calendars and reminders enabled', + }, + { + type: 'separator', + label: 'Event Chooser with Date Dependency', + }, + { + type: 'calendarpicker', + label: 'Date Picker (for Event Dependency)', + key: 'testDateForEvent', + selectedDate: new Date(), + numberOfMonths: 1, + description: 'Select a date to load events for that date in the chooser below', + }, + { + type: 'event-chooser', + label: 'Event Chooser (Depends on Date)', + key: 'testEventDependsOnDate', + sourceDateKey: 'testDateForEvent', + allCalendars: true, + showValue: true, + description: 'This event chooser loads events for the date selected above. Change the date to see different events.', + }, { type: 'separator', label: 'Action Fields', @@ -327,6 +455,76 @@ export async function testFormFieldRender(): Promise { value: 'hidden-value', description: 'Hidden field (not visible but included in form data)', }, + // Note: 'multi-select' is not included in this test because it requires functions + // (multiSelectGetLabel, multiSelectGetValue) that cannot be serialized to JSON when + // passing form fields to the React window. Multi-select fields work in actual forms + // because they are configured in the Form Builder where functions can be stored + // and reconstructed. For testing multi-select, create a real form template instead. + // { + // type: 'multi-select', + // label: 'Multi-Select (Simple Options)', + // key: 'testMultiSelect', + // multiSelectItems: [...], + // multiSelectGetLabel: (item: any): string => item.name, + // multiSelectGetValue: (item: any): string => item.id, + // ... + // }, + { + type: 'separator', + label: 'Markdown Preview', + }, + { + type: 'markdown-preview', + label: 'Markdown Preview (Static Text)', + markdownText: '# Static Markdown\n\nThis is **static** markdown text.\n\n- Item 1\n- Item 2\n- Item 3', + description: 'Markdown preview with static text content', + }, + { + type: 'markdown-preview', + label: 'Markdown Preview (Note by Filename)', + markdownNoteFilename: '📋 Templates/Form Templates/Example Form', + description: 'Markdown preview loading content from a note by filename', + }, + { + type: 'separator', + label: 'Markdown Preview with Note Dependency', + }, + { + type: 'note-chooser', + label: 'Note Chooser (for Markdown Preview)', + key: 'testNoteForMarkdown', + includePersonalNotes: true, + includeCalendarNotes: false, + includeRelativeNotes: false, + includeTeamspaceNotes: true, + showValue: true, + description: 'Select a note to preview its markdown content below', + }, + { + type: 'markdown-preview', + label: 'Markdown Preview (Depends on Note)', + sourceNoteKey: 'testNoteForMarkdown', + description: 'This markdown preview displays the content of the note selected above. Select a note to see its rendered markdown.', + }, + { + type: 'separator', + label: 'Autosave', + }, + { + type: 'autosave', + label: 'Autosave Field', + key: 'testAutosave', + autosaveInterval: 2, + description: 'Automatically saves form state every 2 seconds. Shows "Saved X ago" status.', + }, + { + type: 'autosave', + label: 'Autosave Field (Invisible)', + key: 'testAutosaveInvisible', + autosaveInterval: 5, + invisible: true, + description: 'Invisible autosave field that saves every 5 seconds without showing UI', + }, { type: 'separator', label: 'Form State Viewer', @@ -339,6 +537,8 @@ export async function testFormFieldRender(): Promise { }, // Note: 'orderingPanel' is not included as it's typically used in specific contexts // and may require special setup. Add it if needed for testing. + // Note: 'templatejs-block' is intentionally hidden in DynamicDialog preview (only visible in Form Builder), + // so it's not included in this test. ] const testArgObj = { @@ -365,6 +565,7 @@ export async function testFormFieldRender(): Promise { } logInfo(pluginJson, '📋 Form Field Render Test includes:') logInfo(pluginJson, ' • All DynamicDialog field types') + logInfo(pluginJson, ' • Field dependencies: folder→space, note→folder, heading→note, event→date, markdown→note') logInfo(pluginJson, ' • Folder/note choosers load dynamically when form opens') logInfo(pluginJson, ' • Check Plugin Console for [DIAG] logs showing request/response timing') } catch (error) { diff --git a/dwertheimer.Forms/src/NPTemplateForm.js b/dwertheimer.Forms/src/NPTemplateForm.js index a77433184..8a0c4d3b0 100644 --- a/dwertheimer.Forms/src/NPTemplateForm.js +++ b/dwertheimer.Forms/src/NPTemplateForm.js @@ -4,10 +4,13 @@ import pluginJson from '../plugin.json' import { type PassedData } from './shared/types.js' // Note: getAllNotesAsOptions is no longer used here - FormView loads notes dynamically via requestFromPlugin import { testRequestHandlers, updateFormLinksInNote, removeEmptyLinesFromNote } from './requestHandlers' -import { loadTemplateBodyFromTemplate, loadTemplateRunnerArgsFromTemplate, loadCustomCSSFromTemplate, getFormTemplateList } from './templateIO.js' -import { openFormWindow, openFormBuilderWindow, getFormBrowserWindowId } from './windowManagement.js' +import { loadTemplateBodyFromTemplate, loadTemplateRunnerArgsFromTemplate, loadCustomCSSFromTemplate, getFormTemplateList, findDuplicateFormTemplates } from './templateIO.js' +import { openFormWindow, openFormBuilderWindow, getFormBrowserWindowId, getFormBuilderWindowId, getFormWindowId } from './windowManagement.js' import { log, logError, logDebug, logWarn, timer, clo, JSP, logInfo } from '@helpers/dev' import { showMessage } from '@helpers/userInput' +import { chooseNoteV2 } from '@helpers/NPnote' +import { sendBannerMessage } from '@helpers/HTMLView' +import { isHTMLWindowOpen } from '@helpers/NPWindows' import { waitForCondition } from '@helpers/promisePolyfill' import NPTemplating from 'NPTemplating' import { getNoteByFilename } from '@helpers/note' @@ -16,6 +19,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 @@ -80,11 +85,39 @@ 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) { const options = getFormTemplateList() + const duplicates = findDuplicateFormTemplates(templateTitle) + + if (duplicates.length > 1) { + // Multiple forms with same title found - show warning + const duplicateFilenames = duplicates.map((d) => d.value).join(', ') + const warningMsg = `⚠️ WARNING: Multiple forms found with the title "${templateTitle}". This may cause confusion. Opening the first match. Duplicate files: ${ + duplicates.length + } found.\n\nPlease rename one of these forms to avoid conflicts.\n\nFilenames:\n${duplicates.map((d, i) => `${i + 1}. ${d.value}`).join('\n')}` + + // Try to show banner in any open form/form builder windows + const formBrowserWindowId = getFormBrowserWindowId() + const formBuilderWindowId = getFormBuilderWindowId(templateTitle) + const formWindowId = getFormWindowId(templateTitle) + + if (isHTMLWindowOpen(formBrowserWindowId)) { + await sendBannerMessage(formBrowserWindowId, warningMsg, 'WARN', 10000) + } else if (isHTMLWindowOpen(formBuilderWindowId)) { + await sendBannerMessage(formBuilderWindowId, warningMsg, 'WARN', 10000) + } else if (isHTMLWindowOpen(formWindowId)) { + await sendBannerMessage(formWindowId, warningMsg, 'WARN', 10000) + } else { + // No window open, show regular message + await showMessage(warningMsg) + } + + logWarn(pluginJson, `openTemplateForm: Found ${duplicates.length} forms with title "${templateTitle}": ${duplicateFilenames}`) + } + const chosenOpt = options.find((option) => option.label === templateTitle) if (chosenOpt) { // variable passed is a note title, but we need the filename @@ -111,7 +144,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 +179,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 +194,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 +234,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 +297,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) { @@ -275,6 +308,10 @@ export async function getTemplateFormData(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 { @@ -330,6 +367,34 @@ export async function openFormBuilder(templateTitle?: string): Promise { if (templateTitle?.trim().length) { logDebug(pluginJson, `openFormBuilder: Using provided templateTitle`) const options = getFormTemplateList() + const duplicates = findDuplicateFormTemplates(templateTitle) + + if (duplicates.length > 1) { + // Multiple forms with same title found - show warning + const duplicateFilenames = duplicates.map((d) => d.value).join(', ') + const warningMsg = `⚠️ WARNING: Multiple forms found with the title "${templateTitle}". This may cause confusion. Opening the first match. Duplicate files: ${ + duplicates.length + } found.\n\nPlease rename one of these forms to avoid conflicts.\n\nFilenames:\n${duplicates.map((d, i) => `${i + 1}. ${d.value}`).join('\n')}` + + // Try to show banner in any open form/form builder windows + const formBrowserWindowId = getFormBrowserWindowId() + const formBuilderWindowId = getFormBuilderWindowId(templateTitle) + const formWindowId = getFormWindowId(templateTitle) + + if (isHTMLWindowOpen(formBrowserWindowId)) { + await sendBannerMessage(formBrowserWindowId, warningMsg, 'WARN', 10000) + } else if (isHTMLWindowOpen(formBuilderWindowId)) { + await sendBannerMessage(formBuilderWindowId, warningMsg, 'WARN', 10000) + } else if (isHTMLWindowOpen(formWindowId)) { + await sendBannerMessage(formWindowId, warningMsg, 'WARN', 10000) + } else { + // No window open, show regular message + await showMessage(warningMsg) + } + + logWarn(pluginJson, `openFormBuilder: Found ${duplicates.length} forms with title "${templateTitle}": ${duplicateFilenames}`) + } + const chosenOpt = options.find((option) => option.label === templateTitle) if (chosenOpt) { selectedTemplate = chosenOpt.value @@ -438,6 +503,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', @@ -545,9 +611,80 @@ export async function openFormBuilder(templateTitle?: string): Promise { // $FlowFixMe[incompatible-type] - showOptions returns number index } else if (createNew.index === 1 || createNew.value === 'Edit Existing Form') { logDebug(pluginJson, `openFormBuilder: User chose to edit existing form`) - // Edit existing - selectedTemplate = await NPTemplating.chooseTemplate('template-form') - logDebug(pluginJson, `openFormBuilder: User selected existing template: "${selectedTemplate || 'none'}"`) + + // Filter form templates from all spaces + // Find notes that are: + // - In @Forms folder AND have type 'template-form', OR + // - In @Templates folder AND have type 'template-form' + const allNotes = DataStore.projectNotes + const formTemplateNotes = allNotes.filter((note) => { + const noteType = note.frontmatterAttributes?.type + if (noteType !== 'template-form') { + return false + } + + const filename = note.filename || '' + const isInFormsFolder = filename.includes('@Forms') + const isInTemplatesFolder = filename.includes('@Templates') || (filename.includes('%%NotePlanCloud%%') && filename.includes('@Templates')) + + return isInFormsFolder || isInTemplatesFolder + }) + + if (formTemplateNotes.length === 0) { + await showMessage('No form templates found. Please create a form template first.') + logDebug(pluginJson, `openFormBuilder: No form templates found, returning`) + return + } + + logDebug(pluginJson, `openFormBuilder: Found ${formTemplateNotes.length} form templates from all spaces`) + + // Use chooseNoteV2 to get decorated note selection + const selectedNote = await chooseNoteV2( + 'Choose a form template to edit:', + formTemplateNotes, + false, // includeCalendarNotes + false, // includeFutureCalendarNotes + false, // currentNoteFirst + false, // allowNewRegularNoteCreation + ) + + if (!selectedNote) { + logDebug(pluginJson, `openFormBuilder: User cancelled note selection, returning`) + return + } + + selectedTemplate = selectedNote.filename || '' + logDebug(pluginJson, `openFormBuilder: User selected existing template: "${selectedNote.title || 'none'}" at "${selectedTemplate}"`) + + // Check for duplicates after selection + const selectedNoteTitle = selectedNote.title || selectedNote.filename || '' + if (selectedNoteTitle) { + const duplicates = findDuplicateFormTemplates(selectedNoteTitle) + if (duplicates.length > 1) { + const duplicateFilenames = duplicates.map((d) => d.value).join(', ') + const warningMsg = `⚠️ WARNING: Multiple forms found with the title "${selectedNoteTitle}". This may cause confusion. Duplicate files: ${ + duplicates.length + } found.\n\nPlease rename one of these forms to avoid conflicts.\n\nFilenames:\n${duplicates.map((d, i) => `${i + 1}. ${d.value}`).join('\n')}` + + // Try to show banner in any open form/form builder windows + const formBrowserWindowId = getFormBrowserWindowId() + const formBuilderWindowId = getFormBuilderWindowId(selectedNoteTitle) + const formWindowId = getFormWindowId(selectedNoteTitle) + + if (isHTMLWindowOpen(formBrowserWindowId)) { + await sendBannerMessage(formBrowserWindowId, warningMsg, 'WARN', 10000) + } else if (isHTMLWindowOpen(formBuilderWindowId)) { + await sendBannerMessage(formBuilderWindowId, warningMsg, 'WARN', 10000) + } else if (isHTMLWindowOpen(formWindowId)) { + await sendBannerMessage(formWindowId, warningMsg, 'WARN', 10000) + } else { + // No window open, show regular message + await showMessage(warningMsg) + } + + logWarn(pluginJson, `openFormBuilder: Found ${duplicates.length} forms with title "${selectedNoteTitle}": ${duplicateFilenames}`) + } + } } else { logDebug(pluginJson, `openFormBuilder: User cancelled, returning`) return // cancelled @@ -650,7 +787,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 +881,213 @@ 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: ${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 bee8b9eba..cd3ce95e2 100644 --- a/dwertheimer.Forms/src/components/FieldEditor.jsx +++ b/dwertheimer.Forms/src/components/FieldEditor.jsx @@ -15,6 +15,18 @@ type FieldEditorProps = { requestFromPlugin?: (command: string, dataToSend?: any, timeout?: number) => Promise, // Optional function to call plugin commands } +/** + * Validate CSS width value + * @param {string} value - The width value to validate + * @returns {boolean} - True if valid CSS width value + */ +function isValidCSSWidth(value: string): boolean { + if (!value || value.trim() === '') return true // Empty is valid (means use default) + // Match valid CSS width values: px, %, em, rem, vw, vh, ch, ex, cm, mm, in, pt, pc, or calc() + const cssWidthRegex = /^(\d+(\.\d+)?(px|%|em|rem|vw|vh|ch|ex|cm|mm|in|pt|pc)|calc\([^)]+\)|auto|inherit|initial|unset|max-content|min-content|fit-content)$/i + return cssWidthRegex.test(value.trim()) +} + export function FieldEditor({ field, allFields, onSave, onCancel, requestFromPlugin }: FieldEditorProps): Node { const [editedField, setEditedField] = useState({ ...field }) const [calendars, setCalendars] = useState>([]) @@ -24,6 +36,7 @@ export function FieldEditor({ field, allFields, onSave, onCancel, requestFromPlu const calendarsLoadingRef = useRef(false) const reminderListsLoadingRef = useRef(false) const requestFromPluginRef = useRef(requestFromPlugin) + const [widthError, setWidthError] = useState('') // Track previous field key to detect actual field changes const prevFieldKeyRef = useRef(field.key) @@ -185,7 +198,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 +237,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' && (
)} + {/* Width field for SearchableChooser-based fields */} + {(editedField.type === 'folder-chooser' || + editedField.type === 'note-chooser' || + editedField.type === 'space-chooser' || + editedField.type === 'heading-chooser' || + editedField.type === 'dropdown-select' || + editedField.type === 'event-chooser') && ( +
+ + { + const widthValue = e.target.value.trim() + const updated = { ...editedField } + if (widthValue === '') { + delete (updated: any).width + setWidthError('') + } else if (isValidCSSWidth(widthValue)) { + ;(updated: any).width = widthValue + setWidthError('') + } else { + setWidthError('Invalid CSS width value. Use px, %, em, rem, vw, vh, or calc()') + } + setEditedField(updated) + }} + placeholder="e.g., 80vw, 79%, 300px, calc(100% - 20px)" + style={{ borderColor: widthError ? 'red' : undefined }} + /> +
+ {widthError ? ( + {widthError} + ) : ( + <> + Custom width for the chooser input. Overrides default width even in compact mode. Examples: 80vw, 79%, 300px,{' '} + calc(100% - 20px). Leave empty to use default width. + + )} +
+
+ )} + {editedField.type !== 'separator' && (
@@ -407,34 +462,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' && (
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
+
+
+ + +
+ If specified, folders will be filtered by the space selected in the space-chooser field. This is a value dependency - the field needs the value + from another field to function. +
+
)} @@ -592,6 +668,76 @@ 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
+
+
+ +
+ When enabled, displays only the note title in the label (not "path / title"). The path will still appear in the short description if enabled. +
+
+ + )} + + {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. + 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
+
)} @@ -849,6 +995,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
+
)} @@ -947,6 +1108,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
+
)} @@ -1136,6 +1312,64 @@ export function FieldEditor({ field, allFields, onSave, onCancel, requestFromPlu )} + {editedField.type === 'autosave' && ( + <> +
+ + { + const updated = { ...editedField } + const value = parseInt(e.target.value, 10) + ;(updated: any).autosaveInterval = isNaN(value) || value < 1 ? 2 : value + setEditedField(updated) + }} + placeholder="2" + /> +
+ How often (in seconds) to automatically save the form state. Default is 2 seconds. The form will only save if the content has changed since the last save. +
+
+
+ + '} + onChange={(e) => { + const updated = { ...editedField } + ;(updated: any).autosaveFilename = e.target.value || undefined + setEditedField(updated) + }} + placeholder="@Trash/Autosave-" + /> +
+ Filename pattern for autosave files. Available placeholders: +
    +
  • + <ISO8601> or <timestamp> - Timestamp in local timezone format: YYYY-MM-DDTHH-MM-SS +
  • +
  • + <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. +
+
+
+ +
+ When checked, the autosave field will not display any UI message, but will still automatically save the form state in the background. +
+
+ + )} + {needsKey && (
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/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..2faf1ae5d 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 = { @@ -153,7 +155,7 @@ export function FormBrowserView({ } // State - const [selectedSpace, setSelectedSpace] = useState('') // Empty string = Private (default) + const [selectedSpace, setSelectedSpace] = useState('__all__') // '__all__' = show all spaces (default) const [filterText, setFilterText] = useState('') const [templates, setTemplates] = useState>([]) const [selectedTemplate, setSelectedTemplate] = useState(null) @@ -184,7 +186,7 @@ export function FormBrowserView({ try { setLoading(true) const responseData = await requestFromPlugin('getFormTemplates', { - space: selectedSpace, + space: selectedSpace || '__all__', // Default to showing all spaces if not set }) // requestFromPlugin resolves with the data from the response (result.data from handler) // The handler returns { success: true, data: formTemplates } @@ -213,6 +215,8 @@ export function FormBrowserView({ setLoading(true) const responseData = await requestFromPlugin('getFormFields', { templateFilename: template.filename, + templateTitle: template.label, + windowId: windowIdRef.current || '', }) // requestFromPlugin resolves with the data from the response (result.data from handler) // The handler now returns { success: true, data: { formFields, frontmatter } } @@ -227,7 +231,8 @@ export function FormBrowserView({ if (needsFolders) { try { - const foldersData = await requestFromPlugin('getFolders', {}) + // Pass space: null to get all folders from all spaces (FolderChooser will filter client-side based on spaceFilter prop) + const foldersData = await requestFromPlugin('getFolders', { excludeTrash: true, space: null }) if (Array.isArray(foldersData)) { setFolders(foldersData) } @@ -724,6 +729,8 @@ export function FormBrowserView({ compactDisplay={true} requestFromPlugin={requestFromPlugin} showValue={false} + includeAllOption={true} + shortDescriptionOnLine2={true} />
@@ -804,7 +811,12 @@ export function FormBrowserView({ }} tabIndex={0} > - {template.label} +
+ {template.label} + {selectedSpace === '__all__' && template.spaceTitle && ( + {template.spaceTitle} + )} +
e.stopPropagation()}>
diff --git a/dwertheimer.Forms/src/components/FormSettings.jsx b/dwertheimer.Forms/src/components/FormSettings.jsx index 748d11a36..2859f7313 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%" />
@@ -258,6 +256,14 @@ export function FormSettings({ />
Custom CSS will be saved in a template:ignore customCSS codeblock and injected into the form window when opened. +
+
+ Override Input Width: To change the width of all input fields on this form, add: +
+ + .dynamic-dialog-content {'{'} --dynamic-dialog-input-width: 250px; {'}'} + + Replace 250px with your desired width.
diff --git a/dwertheimer.Forms/src/components/FormView.jsx b/dwertheimer.Forms/src/components/FormView.jsx index 72c76d3fd..666e62881 100644 --- a/dwertheimer.Forms/src/components/FormView.jsx +++ b/dwertheimer.Forms/src/components/FormView.jsx @@ -94,91 +94,96 @@ 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) + // Always load all folders (space: null) so folder-choosers with space dependencies can filter client-side const loadFolders = useCallback(async () => { if (foldersLoaded || loadingFolders || !needsFolders) return try { setLoadingFolders(true) - logDebug('FormView', 'Loading folders on demand...') + logDebug('FormView', 'Loading folders on demand... (all spaces)') // Note: requestFromPlugin resolves with just the data when success=true, or rejects with error when success=false - const foldersData = await requestFromPlugin('getFolders', { excludeTrash: true }) + // Pass space: null to get all folders from all spaces (FolderChooser will filter client-side based on spaceFilter prop) + const foldersData = await requestFromPlugin('getFolders', { excludeTrash: true, space: null }) if (Array.isArray(foldersData)) { setFolders(foldersData) setFoldersLoaded(true) - logDebug('FormView', `Loaded ${foldersData.length} folders`) + logDebug('FormView', `Loaded ${foldersData.length} folders (all spaces)`) } else { logError('FormView', `Failed to load folders: Invalid response format`) setFoldersLoaded(true) // Set to true to prevent infinite retries @@ -192,16 +197,18 @@ export function FormView({ data, dispatch, reactSettings, setReactSettings, onSu }, [foldersLoaded, loadingFolders, needsFolders, requestFromPlugin]) // Reload folders (used after creating a new folder) + // Always load all folders (space: null) so folder-choosers with space dependencies can filter client-side const reloadFolders = useCallback(async () => { try { setLoadingFolders(true) setFoldersLoaded(false) // Reset to allow reload - logDebug('FormView', 'Reloading folders after folder creation...') - const foldersData = await requestFromPlugin('getFolders', { excludeTrash: true }) + logDebug('FormView', 'Reloading folders after folder creation... (all spaces)') + // Pass space: null to get all folders from all spaces + const foldersData = await requestFromPlugin('getFolders', { excludeTrash: true, space: null }) if (Array.isArray(foldersData)) { setFolders(foldersData) setFoldersLoaded(true) - logDebug('FormView', `Reloaded ${foldersData.length} folders`) + logDebug('FormView', `Reloaded ${foldersData.length} folders (all spaces)`) } else { logError('FormView', `Failed to reload folders: Invalid response format`) setFoldersLoaded(true) @@ -300,7 +307,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 +315,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) @@ -566,6 +573,7 @@ export function FormView({ data, dispatch, reactSettings, setReactSettings, onSu { reloadFolders() }} onNotesChanged={() => { reloadNotes() }} + className="template-form" + style={{ content: { paddingLeft: '1.5rem', paddingRight: '1.5rem' } }} />
{/* end of replace */} diff --git a/dwertheimer.Forms/src/components/ProcessingMethodSection.jsx b/dwertheimer.Forms/src/components/ProcessingMethodSection.jsx index 3cdbb13cc..542ed637b 100644 --- a/dwertheimer.Forms/src/components/ProcessingMethodSection.jsx +++ b/dwertheimer.Forms/src/components/ProcessingMethodSection.jsx @@ -67,12 +67,19 @@ export function ProcessingMethodSection({ const [selectedProcessingTemplateFilename, setSelectedProcessingTemplateFilename] = useState('') // Initialize selectedProcessingTemplateFilename from notes when receivingTemplateTitle is set + // Also update when notes load (for timing issue fix) React.useEffect(() => { const currentTitle = frontmatter.receivingTemplateTitle || frontmatter.formProcessorTitle || '' if (currentTitle && notes.length > 0) { - const matchingNote = notes.find((note: NoteOption) => note.title === currentTitle) + const matchingNote = notes.find((note: NoteOption) => { + // Match by title (preferred) or filename + return note.title === currentTitle || note.filename === currentTitle + }) if (matchingNote && matchingNote.filename) { setSelectedProcessingTemplateFilename(matchingNote.filename) + } else { + // If no match found but we have a title, clear the filename to prevent stale state + setSelectedProcessingTemplateFilename('') } } else { setSelectedProcessingTemplateFilename('') @@ -599,7 +606,7 @@ export function ProcessingMethodSection({

To use this method, add a TemplateJS Block field to your form fields list. The JavaScript code in that field will be executed when the form is submitted.

-

+

Form values are available as variables in the TemplateJS code. No note creation or validation is performed - the code runs directly.

@@ -647,7 +654,7 @@ export function ProcessingMethodSection({
-
+
{ onLoadNotes(true) // Load only project notes for processing templates (faster) }} - onOpen={() => { - // Lazy load notes when dropdown opens + onOpen={async () => { + // Lazy load notes when dropdown opens - always reload to ensure fresh data // For processing templates, only need project notes (faster) - if (notes.length === 0) { - onLoadNotes(true).catch((error) => { - console.error('Error loading notes:', error) - }) + try { + await onLoadNotes(true, true) // Force reload to get all available templates + } catch (error) { + console.error('Error loading notes:', error) } }} isLoading={notes.length === 0 && loadingNotes} />
- {selectedProcessingTemplateFilename && ( + {(selectedProcessingTemplateFilename || (frontmatter.receivingTemplateTitle || frontmatter.formProcessorTitle)) && (