diff --git a/main.ts b/main.ts index f0ed959..8c5a64b 100644 --- a/main.ts +++ b/main.ts @@ -18,6 +18,7 @@ import { injectDateNavigatorStyles, removeDateNavigatorStyles } from './src/styl import { registerHabitCounter } from "./src/webcomponents/HabitCounter/registerHabitCounter"; import { registerBooleanSwitch } from "src/webcomponents/BooleanSwitch/registerBooleanSwitch"; +import { registerTextInput } from "src/webcomponents/TextInput/registerTextInput"; export default class SQLiteDBPlugin extends Plugin { settings: SQLiteDBSettings; @@ -36,6 +37,7 @@ export default class SQLiteDBPlugin extends Plugin { this.registerMarkdownPostProcessor((el, ctx) => { registerHabitCounter(el, this.dbService); registerBooleanSwitch(el, this.dbService); + registerTextInput(el, this.app, this.dbService); }); //? Commands diff --git a/src/components/TimePickerModal.ts b/src/components/TimePickerModal.ts new file mode 100644 index 0000000..8413c33 --- /dev/null +++ b/src/components/TimePickerModal.ts @@ -0,0 +1,67 @@ +import { App, Modal, Notice, Setting } from "obsidian"; + +//? A very basic example modal for selecting a time. +export class TimePickerModal extends Modal { + private currentTime: string; // e.g., "HH:MM" + private onSelectCallback: (selectedTime: string) => void; + + constructor(app: App, initialTime: string | undefined, onSelect: (selectedTime: string) => void) { + super(app); + //? Basic validation or default + this.currentTime = initialTime && /^\d{2}:\d{2}$/.test(initialTime) ? initialTime : "12:00"; + this.onSelectCallback = onSelect; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass("time-picker-modal-content"); // For styling + + contentEl.createEl("h2", { text: "Select Time" }); + + // --- Basic Time Input (replace with better UI later) --- + let tempTime = this.currentTime; + new Setting(contentEl) + .setName("Time (HH:MM)") + .addText(text => text + .setValue(tempTime) + .setPlaceholder("HH:MM") + .onChange(value => { + // Basic format check + if (/^\d{1,2}:\d{1,2}$/.test(value)) { + // Format to HH:MM + const parts = value.split(':'); + const hh = parts[0].padStart(2,'0'); + const mm = parts[1].padStart(2,'0'); + // Basic hour/minute range check (can be improved) + if (parseInt(hh) >= 0 && parseInt(hh) <= 23 && parseInt(mm) >= 0 && parseInt(mm) <= 59) { + tempTime = `${hh}:${mm}`; + } else { + // Handle invalid range slightly? Or let confirm check. + } + } + })); + + // --- Action Buttons --- + new Setting(contentEl) + .addButton(button => button + .setButtonText("Cancel") + .onClick(() => this.close())) + .addButton(button => button + .setButtonText("Confirm") + .setCta() // Make it the primary action + .onClick(() => { + //? Final validation before confirming + if (/^\d{2}:\d{2}$/.test(tempTime)) { + this.onSelectCallback(tempTime); + this.close(); + } else { + new Notice("Invalid time format. Use HH:MM."); + } + })); + } + + onClose() { + this.contentEl.empty(); + } +} \ No newline at end of file diff --git a/src/webcomponents/TextInput/TextInput.ts b/src/webcomponents/TextInput/TextInput.ts new file mode 100644 index 0000000..18c105e --- /dev/null +++ b/src/webcomponents/TextInput/TextInput.ts @@ -0,0 +1,157 @@ +import { App } from "obsidian"; +import { DBService } from "src/DBService"; +import { TextInputDataService } from "../services/TextInputDataService"; +import { applyTextInputStyles } from "./styles/applyTextInputStyles"; +import { buildTextInputDOM, } from "./dom/buildTextInputDOM"; +import { createInputChangeHandler } from "./eventHandlers/inputHandlers"; +import { createModalTriggerHandler } from "./eventHandlers/modalTriggerHandlers"; +import { TextInputConfig, ModalType, TextInputEventProps, TextInputDOMElements } from "./TextInput.types"; +import { loadTextValue } from "./data/loadTextValue"; +import { upsertTextValue } from "./data/upsertTextValue"; +import { updateInputValue, setTextErrorState, clearTextErrorState } from "./dom/textInputUiUpdaters"; + +export class TextInput extends HTMLElement { + // --- Dependencies --- + private appInstance: App | null = null; + public textDataService: TextInputDataService | null = null; + + // --- State & Config --- + public config: TextInputConfig = {}; + public currentValue: string = ""; + private _isInitialized: boolean = false; + //^ data related state/config + public table: string = ""; + public date: string = ""; + public valueCol: string = ""; + public dateCol: string = ""; + + // --- Shadow DOM Elements --- + private uiElements!: TextInputDOMElements; + + // --- Lifecycle --- + connectedCallback() { + if (this._isInitialized) return; + // Initialization requires appInstance + } + + // --- Public Methods --- + /** Injects DBService first, then App instance. */ + public setDependencies(dbService: DBService, app: App): void { + if (!dbService || !app) { + console.error("[TextInput] Invalid DBService or App instance provided."); + //? Maybe show an error state on the element directly? + this.textContent = "[Init Error]"; + return; + } + this.appInstance = app; + try { + this.textDataService = new TextInputDataService(dbService); //^ Init Data Service + } catch(error){ + console.error("[TextInput] Failed to initialize TextInputDataService:", error); + this.textContent = "[Data Service Error]"; + return; + } + + if (!this._isInitialized) { + this._readAttributesAndBuildConfig(); + this._initializeComponent(); + this._isInitialized = true; + } + } + + // --- Internal Initialization --- + private _readAttributesAndBuildConfig(): void { + this.config = { + // UI Config + label: this.getAttribute("data-label") ?? undefined, + placeholder: this.getAttribute("placeholder") ?? undefined, + initialValueAttr: this.getAttribute("data-initial-value") ?? "", // Read initial value from attr + modalType: (this.getAttribute("data-modal-type") as ModalType) ?? 'none', + isButton: this.getAttribute("data-is-button")?.toLowerCase() === 'true', //^ Read isButton flag + + // Data Config + table: this.getAttribute("data-table") ?? undefined, + date: this.getAttribute("data-date") ?? "@date", // Default to @date + valueCol: this.getAttribute("data-value-col") ?? undefined, + dateCol: this.getAttribute("data-date-col") ?? undefined, + }; + //? Copy needed values from config/attributes to 'this' properties + this.table = this.config.table ?? ""; + this.date = this.config.date ?? "@date"; + this.valueCol = this.config.valueCol ?? ""; + this.dateCol = this.config.dateCol ?? ""; + } + + private _initializeComponent(): void { + if (!this.appInstance) { + console.error("[TextInput] Cannot initialize: App instance not set."); + return; + } + + const eventProps: TextInputEventProps = { + ...this.config, // Includes data config now + app: this.appInstance, + uiElements: this.uiElements, // Placeholder + currentValue: this.currentValue, + setValue: this._setValue, + }; + + const handlers = { + handleInputChange: createInputChangeHandler(eventProps), + handleModalTrigger: createModalTriggerHandler(eventProps), + }; + + this.uiElements = buildTextInputDOM(this.attachShadow({ mode: "open" }), this.config, handlers); + eventProps.uiElements = this.uiElements; + applyTextInputStyles(this.shadowRoot!); + + //^ --- Trigger Initial Load (if data configured) --- + if (this.config.table && this.config.date && this.config.valueCol && this.config.dateCol) { + loadTextValue(this).catch(err => console.error(`[TextInput ${this.config.table}] Unhandled error during initial load:`, err)); + } else { + //? No data config, just use initialValueAttr + this._updateDisplay(this.config.initialValueAttr ?? ""); + } + + // todo: date change listener if needed + } + + /** Central method to update state, UI, and trigger save. */ + private _setValue = (newValue: string, triggerSave: boolean = false): void => { + if (this.currentValue !== newValue) { + this.currentValue = newValue; + + //? Always update the input display + this._updateDisplay(this.currentValue); + + this.dispatchEvent(new CustomEvent('input-change', { detail: { value: this.currentValue }, /*...*/ })); + + //? Trigger save only if configured and requested + if (triggerSave && this.config.table && this.config.date && this.config.valueCol && this.config.dateCol) { + //? Call the upsert helper + upsertTextValue(this).catch(err => { + console.error(`[TextInput ${this.config.table}] Error during save:`, err); + //? Consider reverting UI or showing persistent error? + // this.showErrorState("Save Failed"); + }); + } + } + }; + + //? Public method called by data helpers to update display + public _updateDisplay(newValue: string): void { + this.currentValue = newValue; // Update internal state + updateInputValue(this.uiElements, newValue); //~ Update DOM via helper + this.clearErrorState(); //? Clear errors on successful update + //? Enable/disable logic might be needed based on state + } + + //? Public methods for UI state helpers + public showErrorState(message?: string): void { setTextErrorState(this, this.uiElements, message); } + public clearErrorState(): void { clearTextErrorState(this, this.uiElements); } + + // todo: disable/enable methods +} + +// --- Custom Element Definition --- +if (!customElements.get("text-input")) { customElements.define("text-input", TextInput); } \ No newline at end of file diff --git a/src/webcomponents/TextInput/TextInput.types.ts b/src/webcomponents/TextInput/TextInput.types.ts new file mode 100644 index 0000000..68ee1f1 --- /dev/null +++ b/src/webcomponents/TextInput/TextInput.types.ts @@ -0,0 +1,54 @@ +import { App } from "obsidian"; + +//? Type defining the available modal types this component can trigger +export type ModalType = 'time-picker' | 'date-picker' | 'none'; + +//? Configuration options read from attributes for TextInput +export interface TextInputConfig { + //? UI Config + label?: string; + placeholder?: string; + initialValueAttr?: string; + modalType?: ModalType; + isButton?: boolean; + + //? Data Config (Similar to BooleanSwitch/HabitCounter) + table?: string; + date?: string; + valueCol?: string; + dateCol?: string; +} + +//? References to the DOM elements for TextInput +export interface TextInputDOMElements { + wrapper: HTMLElement; + labelElement: HTMLLabelElement | null; + inputElement: HTMLInputElement; + modalTriggerElement: HTMLElement; +} + +//? Properties needed by event handlers for TextInput +export interface TextInputEventProps extends TextInputConfig { + app: App; + uiElements: TextInputDOMElements; + currentValue: string; + setValue: (newValue: string, triggerSave?: boolean) => void; +} + +//? Types for the Data Service +export interface TextRecord { + value: string | null; // Value can be text or null +} + +//? Base arguments for identifying an entry in DB +export interface TextDataArgs { + table: string; + date: string; // Date to identify the row + valueCol: string; // Column to fetch/update + dateCol: string; // Column containing the date key +} + +//? Arguments for upserting the text value +export interface UpsertTextArgs extends TextDataArgs { + newValue: string | null; // Allow saving null/empty string +} \ No newline at end of file diff --git a/src/webcomponents/TextInput/data/loadTextValue.ts b/src/webcomponents/TextInput/data/loadTextValue.ts new file mode 100644 index 0000000..958b004 --- /dev/null +++ b/src/webcomponents/TextInput/data/loadTextValue.ts @@ -0,0 +1,61 @@ +import { TextInputDataService } from "../../services/TextInputDataService"; +import { calculateEffectiveDate } from "../../services/utils/calculateEffectiveDate"; + +/** Interface describing the necessary parts of the TextInput component instance for loading. */ +interface LoadTextComponentInstance { + textDataService: TextInputDataService | null; // Use the correct service + table: string; + date: string; // The initial date string ('@date' or 'YYYY-MM-DD') + valueCol: string; + dateCol: string; + _updateDisplay: (value: string) => void; // Method to update component UI/state + showErrorState: (message: string) => void; // Method to show error UI + config: { initialValueAttr?: string }; // Access to initial value from attribute if DB fails/missing +} + +/** + * Orchestrates loading the text value using the TextInputDataService. + * @param instance - The TextInput component instance. + */ +export async function loadTextValue(instance: LoadTextComponentInstance): Promise { + if (!instance.textDataService) { + console.error(`[LoadTextValue ${instance.table}] Cannot load data: Missing textDataService.`); + //? Use initial value from attribute as fallback before showing error? + instance._updateDisplay(instance.config.initialValueAttr ?? ""); + instance.showErrorState("Load Error - DB"); + return; + } + + const missingProps: string[] = []; + if (!instance.table) missingProps.push("table"); + if (!instance.date) missingProps.push("date"); + if (!instance.valueCol) missingProps.push("valueCol"); + if (!instance.dateCol) missingProps.push("dateCol"); + + if (missingProps.length > 0) { + const missingPropsStr = missingProps.join(", "); + console.error(`[LoadTextValue ${instance.table || 'UNKNOWN'}] Cannot load data: Missing required configuration properties: ${missingPropsStr}.`); + //? Use initial value from attribute as fallback before showing error? + instance._updateDisplay(instance.config.initialValueAttr ?? ""); + instance.showErrorState("Load Error - Config"); + return; + } + + const args = { + table: instance.table, + date: calculateEffectiveDate(instance.date), // Use effective date + valueCol: instance.valueCol, + dateCol: instance.dateCol, + }; + + try { + const value = await instance.textDataService.fetchTextValue(args); + instance._updateDisplay(value); // Update component with fetched value (or empty string if not found) + + } catch (error) { + console.error(`[LoadTextValue ${instance.table}] Failed to load data:`, error); + //? Use initial value from attribute as fallback on DB error + instance._updateDisplay(instance.config.initialValueAttr ?? ""); + instance.showErrorState("Load Error - DB"); + } +} \ No newline at end of file diff --git a/src/webcomponents/TextInput/data/upsertTextValue.ts b/src/webcomponents/TextInput/data/upsertTextValue.ts new file mode 100644 index 0000000..f96b0a3 --- /dev/null +++ b/src/webcomponents/TextInput/data/upsertTextValue.ts @@ -0,0 +1,49 @@ +import { TextInputDataService } from "../../services/TextInputDataService"; +import { calculateEffectiveDate } from "../../services/utils/calculateEffectiveDate"; +import { Notice } from "obsidian"; + +//? Interface describing the necessary parts of the TextInput component instance for upserting. +interface UpsertTextComponentInstance { + textDataService: TextInputDataService | null; + table: string; + date: string; // The initial date string ('@date' or 'YYYY-MM-DD') + currentValue: string; // The current value held by the component + valueCol: string; + dateCol: string; + _updateDisplay: (value: string) => void; // Method to update UI (maybe not needed here if optimistic?) + showErrorState: (message: string) => void; + clearErrorState: () => void; +} + +/** + * Orchestrates upserting the text value via the TextInputDataService. + * @param instance - The TextInput component instance. + */ +export async function upsertTextValue(instance: UpsertTextComponentInstance): Promise { + if (!instance.textDataService || !instance.table || !instance.date || !instance.valueCol || !instance.dateCol) { + console.error(`[UpsertTextValue ${instance.table}] Cannot save data: Missing required configuration properties.`); + instance.showErrorState("Save Error - Config"); + new Notice("Cannot save text input: Configuration incomplete."); + return; + } + + const args = { + table: instance.table, + date: calculateEffectiveDate(instance.date), + newValue: instance.currentValue, + valueCol: instance.valueCol, + dateCol: instance.dateCol, + }; + + try { + await instance.textDataService.upsertTextValue(args); + instance.clearErrorState(); + + } catch (error) { + console.error(`[UpsertTextValue ${instance.table}] Failed to save data:`, error); + instance.showErrorState("Save Error - DB"); + new Notice(`Error saving value for ${instance.table}`); + //? Should we revert the UI? Depends on desired UX. + //? For now, keep the user's input but show error state. + } +} \ No newline at end of file diff --git a/src/webcomponents/TextInput/dom/buildTextInputDOM.ts b/src/webcomponents/TextInput/dom/buildTextInputDOM.ts new file mode 100644 index 0000000..4358517 --- /dev/null +++ b/src/webcomponents/TextInput/dom/buildTextInputDOM.ts @@ -0,0 +1,83 @@ +import type { TextInputConfig, TextInputDOMElements } from "../TextInput.types"; +import type { InputChangeEventHandler } from "../eventHandlers/inputHandlers"; +import type { ModalTriggerHandler } from "../eventHandlers/modalTriggerHandlers"; + +//? Interface for handlers needed by the DOM builder +export interface TextInputDOMHandlers { + handleInputChange: InputChangeEventHandler; + handleModalTrigger: ModalTriggerHandler; +} + +/** + * Builds the Shadow DOM structure for the TextInput component. + * @param shadowRoot The ShadowRoot to append elements to. + * @param config Configuration options from attributes. + * @param handlers Event handlers for input change and modal trigger. + * @returns Object containing references to the created UI elements. + */ +export function buildTextInputDOM( + shadowRoot: ShadowRoot, + config: TextInputConfig, + handlers: TextInputDOMHandlers +): TextInputDOMElements { + if (!shadowRoot) throw new Error("Cannot build DOM: ShadowRoot is null."); + + //? Determine if a separate button is needed *initially* + //? Button mode overrides this later if needed + const needsSeparateButton = config.modalType && config.modalType !== 'none' && !config.isButton; + + // --- Optional Label --- + let labelElement: HTMLLabelElement | null = null; + if (config.label) { + labelElement = document.createElement("label"); + labelElement.textContent = config.label; + labelElement.className = "input-label"; + //? Associate label with input later via ID + shadowRoot.appendChild(labelElement); // Append label first if present + } + + // --- Wrapper for Input and (optional) Button --- + const inputWrapper = document.createElement("div"); + inputWrapper.className = "input-wrapper"; + + const inputElement = document.createElement("input"); + inputElement.type = "text"; + inputElement.className = "main-input"; + inputElement.value = config.initialValueAttr ?? ""; // Use value from attribute initially + inputElement.placeholder = config.placeholder ?? ""; + + //^ Apply button mode logic + if (config.isButton) { + inputElement.readOnly = true; // Make input non-editable + //? Attach modal trigger directly to input click + inputElement.addEventListener("click", handlers.handleModalTrigger); + //? Add data attribute to host for styling + (shadowRoot.host as HTMLElement).dataset.buttonMode = 'true'; + } else { + //? Normal input mode - listen for changes + inputElement.addEventListener("change", handlers.handleInputChange); + } + + // ... (associate label if present) ... + inputWrapper.appendChild(inputElement); + + let modalTriggerElement: HTMLElement = inputElement; // Default trigger is input + + //^ Create separate button only if needed AND not in button mode + if (needsSeparateButton) { + const buttonElement = document.createElement("button"); + // ... (create button content/attributes) ... + buttonElement.addEventListener("click", handlers.handleModalTrigger); + inputWrapper.appendChild(buttonElement); + modalTriggerElement = buttonElement; // Button is now the trigger + } + + shadowRoot.appendChild(inputWrapper); + + return { + wrapper: shadowRoot.host as HTMLElement, + labelElement, + inputElement, + modalTriggerElement // Might be input or button depending on mode + }; +} \ No newline at end of file diff --git a/src/webcomponents/TextInput/dom/textInputUiUpdaters.ts b/src/webcomponents/TextInput/dom/textInputUiUpdaters.ts new file mode 100644 index 0000000..27d16b2 --- /dev/null +++ b/src/webcomponents/TextInput/dom/textInputUiUpdaters.ts @@ -0,0 +1,49 @@ +import type { TextInputDOMElements } from "../TextInput.types"; + +/** Updates the value displayed in the input element. */ +export function updateInputValue(elements: TextInputDOMElements, newValue: string): void { + if (elements.inputElement && elements.inputElement.value !== newValue) { + elements.inputElement.value = newValue; + } else if (!elements.inputElement) { + console.error("[TextInputUI] inputElement not found for value update."); + } +} + +/** Updates static UI parts like the label text. */ +export function updateStaticTextUI(elements: TextInputDOMElements, label?: string): void { + if (elements.labelElement && label) { + elements.labelElement.textContent = label; + } + //? Could add updates for aria-labels here if needed +} + +/** Sets the component to visually indicate an error state and disables input/button. */ +export function setTextErrorState(hostElement: HTMLElement, elements: TextInputDOMElements, message: string = "Error"): void { + if (elements.inputElement) { + elements.inputElement.disabled = true; // Disable input on error + elements.inputElement.style.borderColor = "var(--text-error)"; // Example styling + } + if (elements.modalTriggerElement && elements.modalTriggerElement !== elements.inputElement) { + // Disable separate button if it exists + (elements.modalTriggerElement as HTMLButtonElement).disabled = true; + (elements.modalTriggerElement as HTMLButtonElement).style.borderColor = "var(--text-error)"; + } + hostElement.classList.add('error'); + hostElement.setAttribute('aria-disabled', 'true'); // Indicate disabled state + hostElement.setAttribute('title', message); +} + +/** Clears any visual error indication and enables input/button. */ +export function clearTextErrorState(hostElement: HTMLElement, elements: TextInputDOMElements): void { + if (elements.inputElement) { + elements.inputElement.disabled = false; + elements.inputElement.style.borderColor = ""; // Reset border + } + if (elements.modalTriggerElement && elements.modalTriggerElement !== elements.inputElement) { + (elements.modalTriggerElement as HTMLButtonElement).disabled = false; + (elements.modalTriggerElement as HTMLButtonElement).style.borderColor = ""; + } + hostElement.classList.remove('error'); + hostElement.removeAttribute('aria-disabled'); + hostElement.removeAttribute('title'); +} \ No newline at end of file diff --git a/src/webcomponents/TextInput/eventHandlers/inputHandlers.ts b/src/webcomponents/TextInput/eventHandlers/inputHandlers.ts new file mode 100644 index 0000000..913ac34 --- /dev/null +++ b/src/webcomponents/TextInput/eventHandlers/inputHandlers.ts @@ -0,0 +1,13 @@ +import type { TextInputEventProps } from "../TextInput.types"; + +export type InputChangeEventHandler = (event: Event) => void; + +/** Creates the handler for the input element's 'change' event. */ +export function createInputChangeHandler(props: TextInputEventProps): InputChangeEventHandler { + return (event: Event) => { + if (event.target instanceof HTMLInputElement) { + const newValue = event.target.value; + props.setValue(newValue, true); + } + }; +} \ No newline at end of file diff --git a/src/webcomponents/TextInput/eventHandlers/modalTriggerHandlers.ts b/src/webcomponents/TextInput/eventHandlers/modalTriggerHandlers.ts new file mode 100644 index 0000000..5bbe638 --- /dev/null +++ b/src/webcomponents/TextInput/eventHandlers/modalTriggerHandlers.ts @@ -0,0 +1,35 @@ +import type { TextInputEventProps } from "../TextInput.types"; +import { TimePickerModal } from "../../../components/TimePickerModal"; +import { DatePickerModal } from "../../../codeblocks/dateHeader/components/datePicker/DatePickerModal"; + +export type ModalTriggerHandler = (event: MouseEvent) => void; + +/** //? Creates the handler for the modal trigger element's 'click' event. */ +export function createModalTriggerHandler(props: TextInputEventProps): ModalTriggerHandler { // Use renamed type + return (event: MouseEvent) => { + // No debug log needed at start + + switch (props.modalType) { + case 'time-picker': + new TimePickerModal(props.app, props.currentValue, (selectedTime: string) => { + props.setValue(selectedTime, true); + }).open(); + break; + + case 'date-picker': + const initialDate = props.currentValue || new Date().toISOString().split('T')[0]; + new DatePickerModal(props.app, initialDate, (selectedDate: string) => { + props.setValue(selectedDate, true); + }).open(); + break; + + // todo: Add cases for 'list-select', 'custom', etc. + + case 'none': + default: + // Do nothing or console.warn if unexpected click + // console.warn(`[ModalTriggerHandler ${props.label}] Clicked trigger but modalType is '${props.modalType}'.`); + break; + } + }; +} \ No newline at end of file diff --git a/src/webcomponents/TextInput/registerTextInput.ts b/src/webcomponents/TextInput/registerTextInput.ts new file mode 100644 index 0000000..f032b3a --- /dev/null +++ b/src/webcomponents/TextInput/registerTextInput.ts @@ -0,0 +1,57 @@ +import { App } from "obsidian"; +import { TextInput } from "./TextInput"; +import { DBService } from "src/DBService"; + +//? Define element if needed +if (!customElements.get("text-input")) { + customElements.define("text-input", TextInput); +} + +/** + * Finds placeholder elements and replaces them with initialized TextInput components. + * @param el The container element to search within. + * @param app The Obsidian App instance. + */ +export const registerTextInput = (el: HTMLElement, app: App, dbService: DBService) => { + const placeholders = el.querySelectorAll("span.text-input-placeholder"); + + placeholders.forEach((placeholderEl) => { + if (!(placeholderEl instanceof HTMLElement)) return; + + //~ Read all attributes + const configAttrs: Record = { + label: placeholderEl.dataset.label, + placeholder: placeholderEl.dataset.placeholder, + initialValue: placeholderEl.dataset.initialValue, + modalType: placeholderEl.dataset.modalType, + isButton: placeholderEl.dataset.isButton, // Read button mode flag + table: placeholderEl.dataset.table, + date: placeholderEl.dataset.date, + valueCol: placeholderEl.dataset.valueCol, + dateCol: placeholderEl.dataset.dateCol, + }; + + try { + const component = document.createElement("text-input") as TextInput; + + //~ Set all attributes on the component element + Object.entries(configAttrs).forEach(([key, value]) => { + if (value !== undefined) { + // Convert camelCase (like isButton) to kebab-case for attributes + const attrName = key.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`); + component.setAttribute(`data-${attrName}`, value); + } + }); + // Manually set placeholder if needed as it's not a data-* attribute + if (configAttrs.placeholder) component.setAttribute("placeholder", configAttrs.placeholder); + + //^ Inject BOTH dependencies AFTER setting attributes + component.setDependencies(dbService, app); + + placeholderEl.replaceWith(component); + } catch (error) { + console.error(`[TextInput Reg] Error processing placeholder:`, error, placeholderEl); // Keep error log + placeholderEl.textContent = "[Error Loading Input]"; + } + }); +} \ No newline at end of file diff --git a/src/webcomponents/TextInput/styles/applyTextInputStyles.ts b/src/webcomponents/TextInput/styles/applyTextInputStyles.ts new file mode 100644 index 0000000..a21ae55 --- /dev/null +++ b/src/webcomponents/TextInput/styles/applyTextInputStyles.ts @@ -0,0 +1,12 @@ +import { getTextInputStyles } from "./getTextInputStyles"; + +/** Creates a style element and appends it to the shadow root. */ +export function applyTextInputStyles(shadowRoot: ShadowRoot): void { + if (!shadowRoot) { + console.error("[TextInputStyles] Cannot apply styles: ShadowRoot is null."); + return; + } + const style = document.createElement("style"); + style.textContent = getTextInputStyles(); + shadowRoot.appendChild(style); +} \ No newline at end of file diff --git a/src/webcomponents/TextInput/styles/getTextInputStyles.ts b/src/webcomponents/TextInput/styles/getTextInputStyles.ts new file mode 100644 index 0000000..2c86c64 --- /dev/null +++ b/src/webcomponents/TextInput/styles/getTextInputStyles.ts @@ -0,0 +1,133 @@ +/** Returns CSS string for the TextInput component's Shadow DOM. */ +export function getTextInputStyles(): string { + return ` + :host { + display: inline-flex; /* Or block, depending on desired layout */ + flex-direction: column; /* Stack label and input/button */ + gap: var(--size-4-1); /* Space between label and input row */ + font-family: var(--font-text); + width: 100%; /* Default to full width, can be overridden */ + } + + .input-wrapper { + display: flex; + align-items: stretch; /* Make input and button same height */ + width: 100%; + } + + /* Optional Label Styling */ + .input-label { + font-size: var(--font-ui-small); + font-weight: var(--font-semibold); + color: var(--text-muted); + padding-left: var(--size-4-2); /* Indent slightly */ + margin-bottom: -var(--size-4-1); /* Pull input closer if label exists */ + } + + /* Main Input Element Styling */ + input[type="text"].main-input { + flex-grow: 1; /* Take available space */ + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + padding: var(--size-4-2) var(--size-4-3); + font-size: var(--font-ui-normal); + background-color: var(--background-secondary); + color: var(--text-normal); + min-width: 50px; /* Prevent collapsing too small */ + transition: border-color 0.1s ease-in-out; + } + input[type="text"].main-input:focus { + outline: none; + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--interactive-accent-translucent); + } + input[type="text"].main-input::placeholder { + color: var(--text-faint); + opacity: 0.8; + } + + input[type="text"].main-input { + /* ... existing input styles ... */ + /* //? Default cursor */ + cursor: text; + } + + /* //^ Styles for Button Mode */ + :host([data-button-mode="true"]) input[type="text"].main-input { + cursor: pointer; /* Indicate clickability */ + } + :host([data-button-mode="true"]) input[type="text"].main-input:hover { + background-color: var(--background-modifier-hover); + } + /* //? Hide the separate trigger button in button mode */ + :host([data-button-mode="true"]) button.modal-trigger { + display: none; + } + /* //? Ensure input border radius is correct in button mode */ + :host([data-button-mode="true"]) input[type="text"].main-input { + border-top-right-radius: var(--radius-m); + border-bottom-right-radius: var(--radius-m); + } + + /* Modal Trigger Button Styling (if used) */ + button.modal-trigger { + flex-shrink: 0; /* Don't shrink the button */ + border: 1px solid var(--background-modifier-border); + border-left: none; /* Join with input */ + background-color: var(--background-secondary); + color: var(--text-muted); + padding: 0 var(--size-4-3); + cursor: pointer; + border-top-right-radius: var(--radius-m); + border-bottom-right-radius: var(--radius-m); + font-size: var(--font-ui-small); + display: flex; + align-items: center; + justify-content: center; + } + /* Adjust input border radius when button is present */ + input[type="text"].main-input + button.modal-trigger { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + button.modal-trigger:hover { + background-color: var(--background-modifier-hover); + color: var(--text-normal); + } + button.modal-trigger:active { + background-color: var(--background-modifier-active); + } + + /* Styling when input itself is the trigger (no separate button) */ + :host([data-input-as-trigger="true"]) input[type="text"].main-input { + cursor: pointer; /* Indicate clickability */ + /* Optionally change appearance slightly */ + /* background-image: url('data:image/svg+xml,...'); // Add dropdown arrow */ + } + :host([data-input-as-trigger="true"]) input[type="text"].main-input:hover { + /* background-color: var(--background-modifier-hover); // Subtle hover on input */ + } + + + /* Error State */ + :host(.error) input[type="text"].main-input, + :host(.error) button.modal-trigger { + border-color: var(--text-error); + } + :host(.error) input[type="text"].main-input:focus { + box-shadow: 0 0 0 2px var(--text-error-translucent); + } + + /* Disabled State */ + :host([aria-disabled="true"]) { + opacity: 0.6; + cursor: not-allowed; + } + :host([aria-disabled="true"]) input[type="text"].main-input, + :host([aria-disabled="true"]) button.modal-trigger { + background-color: var(--background-modifier-border-hover); + cursor: not-allowed; + } + `; +} \ No newline at end of file diff --git a/src/webcomponents/services/TextInputDataService.ts b/src/webcomponents/services/TextInputDataService.ts new file mode 100644 index 0000000..681bfd1 --- /dev/null +++ b/src/webcomponents/services/TextInputDataService.ts @@ -0,0 +1,83 @@ +import { DBService } from "src/DBService"; +import { TextRecord, TextDataArgs, UpsertTextArgs } from "../TextInput/TextInput.types"; +import { quoteSqlIdentifier } from "./utils/quoteSqlIdentifier"; +import { validateBaseTextArgs, validateUpsertTextArgs } from "./utils/validateTextInputArgs"; + +export class TextInputDataService { + private dbService: DBService; + constructor(dbService: DBService) { + if (!dbService) throw new Error("TextInputDataService requires a valid DBService instance."); + this.dbService = dbService; + } + + /** //? Fetches the current text value for a specific key/date. Returns empty string if not found. */ + async fetchTextValue(args: TextDataArgs): Promise { + try { + validateBaseTextArgs(args); + const safeTable = quoteSqlIdentifier(args.table); + const safeValueCol = quoteSqlIdentifier(args.valueCol); + const safeDateCol = quoteSqlIdentifier(args.dateCol); + + const sql = `SELECT ${safeValueCol} AS value FROM ${safeTable} WHERE ${safeDateCol} = ?`; + const params = [args.date]; + + const result = await this.dbService.getQuery(sql, params); + const value = result?.[0]?.value; + //? Return fetched string or empty string if null/undefined/not found + return typeof value === 'string' ? value : ""; + + } catch (error) { + console.error(`[TextInputDataService] Error in fetchTextValue for ${args.table}:`, error); + throw error; + } + } + + /** //? Upserts the text value. Handles optional UUID column logic. */ + async upsertTextValue(args: UpsertTextArgs): Promise { + try { + validateUpsertTextArgs(args); + + const safeTable = quoteSqlIdentifier(args.table); + const safeValueCol = quoteSqlIdentifier(args.valueCol); + const safeDateCol = quoteSqlIdentifier(args.dateCol); + const updatedAtCol = quoteSqlIdentifier("updatedAt"); //! 🔒 Hardcoded + + const now = new Date().toISOString(); //~ Current timestamp for updatedAt + + let sql = ""; + let params: (string | number | null)[] = []; // Allow null for value + //? Ensure newValue is null if it's an empty string, if desired by DB schema + const valueToSave = args.newValue === "" ? null : args.newValue; + + const fetchUuidSql = `SELECT uuid FROM ${safeTable} WHERE ${safeDateCol} = ?`; + const uuidResult = await this.dbService.getQuery<{ uuid: string }>(fetchUuidSql, [args.date]); + const existingUuid = uuidResult?.[0]?.uuid; + + if (existingUuid) { + sql = `UPDATE ${safeTable} SET ${safeValueCol} = ?, ${updatedAtCol} = ? WHERE uuid = ?`; + params = [valueToSave, now, existingUuid]; + } else { + // Standard UPSERT Path + sql = ` + INSERT INTO ${safeTable} (${safeDateCol}, ${safeValueCol}, ${updatedAtCol}) + VALUES (?, ?, ?) + ON CONFLICT(${safeDateCol}) DO UPDATE + SET ${safeValueCol} = excluded.${safeValueCol}, ${updatedAtCol} = excluded.${updatedAtCol}; + `; + //! Requires UNIQUE constraint on (keyIdCol, dateCol) + params = [args.date, valueToSave, now]; + } + + await this.dbService.runQuery(sql, params); + + } catch (error) { + console.error(`[TextInputDataService] Error in upsertTextValue for ${args.table}:`, error); + if (error instanceof Error && error.message.includes("ON CONFLICT clause does not match")) { + //^ Provide more specific guidance mentioning the user-provided columns + console.error(`//! DB Constraint Error: Table '${args.table}' needs a UNIQUE index/key on (${args.dateCol}). Run: CREATE UNIQUE INDEX IF NOT EXISTS idx_key_date ON ${quoteSqlIdentifier(args.table)} (${quoteSqlIdentifier(args.dateCol)});`); + throw new Error(`Missing UNIQUE constraint on (${args.dateCol}) in table ${args.table}. ${error.message}`); + } + throw error; + } + } +} \ No newline at end of file diff --git a/src/webcomponents/services/utils/validateTextInputArgs.ts b/src/webcomponents/services/utils/validateTextInputArgs.ts new file mode 100644 index 0000000..ac1025c --- /dev/null +++ b/src/webcomponents/services/utils/validateTextInputArgs.ts @@ -0,0 +1,25 @@ +// src/webcomponents/services/utils/validateTextInputArgs.ts +import { TextDataArgs, UpsertTextArgs } from "src/webcomponents/TextInput/TextInput.types"; + +const IDENTIFIER_REGEX = /^[a-zA-Z0-9_]+$/; + +/** //? Validates base arguments required for identifying text input data row by date. */ +export function validateBaseTextArgs(args: TextDataArgs): void { + if (!args.table || !args.date) { //^ Need table and date + throw new Error(`Missing required identifying arguments: table='${args.table}', date='${args.date}'`); + } + if (!args.valueCol || !IDENTIFIER_REGEX.test(args.valueCol)) { //^ Need value column + throw new Error(`Invalid or missing value column name: '${args.valueCol}'`); + } + if (!args.dateCol || !IDENTIFIER_REGEX.test(args.dateCol)) { //^ Need date column + throw new Error(`Invalid or missing date column name: '${args.dateCol}'`); + } +} + +/** //? Validates arguments required for upserting text input data. */ +export function validateUpsertTextArgs(args: UpsertTextArgs): void { + validateBaseTextArgs(args); + if (typeof args.newValue !== 'string' && args.newValue !== null) { + throw new Error(`Invalid or missing newValue: '${args.newValue}'`); + } +} \ No newline at end of file