mirror of
https://github.com/stfrigerio/sqliteDB.git
synced 2026-07-22 05:38:02 +00:00
created text input
This commit is contained in:
parent
6ca1207333
commit
d8cc77f863
15 changed files with 880 additions and 0 deletions
2
main.ts
2
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
|
||||
|
|
|
|||
67
src/components/TimePickerModal.ts
Normal file
67
src/components/TimePickerModal.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
157
src/webcomponents/TextInput/TextInput.ts
Normal file
157
src/webcomponents/TextInput/TextInput.ts
Normal file
|
|
@ -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); }
|
||||
54
src/webcomponents/TextInput/TextInput.types.ts
Normal file
54
src/webcomponents/TextInput/TextInput.types.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
61
src/webcomponents/TextInput/data/loadTextValue.ts
Normal file
61
src/webcomponents/TextInput/data/loadTextValue.ts
Normal file
|
|
@ -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<void> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
49
src/webcomponents/TextInput/data/upsertTextValue.ts
Normal file
49
src/webcomponents/TextInput/data/upsertTextValue.ts
Normal file
|
|
@ -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<void> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
83
src/webcomponents/TextInput/dom/buildTextInputDOM.ts
Normal file
83
src/webcomponents/TextInput/dom/buildTextInputDOM.ts
Normal file
|
|
@ -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
|
||||
};
|
||||
}
|
||||
49
src/webcomponents/TextInput/dom/textInputUiUpdaters.ts
Normal file
49
src/webcomponents/TextInput/dom/textInputUiUpdaters.ts
Normal file
|
|
@ -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');
|
||||
}
|
||||
13
src/webcomponents/TextInput/eventHandlers/inputHandlers.ts
Normal file
13
src/webcomponents/TextInput/eventHandlers/inputHandlers.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
57
src/webcomponents/TextInput/registerTextInput.ts
Normal file
57
src/webcomponents/TextInput/registerTextInput.ts
Normal file
|
|
@ -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<string, string | undefined> = {
|
||||
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]";
|
||||
}
|
||||
});
|
||||
}
|
||||
12
src/webcomponents/TextInput/styles/applyTextInputStyles.ts
Normal file
12
src/webcomponents/TextInput/styles/applyTextInputStyles.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
133
src/webcomponents/TextInput/styles/getTextInputStyles.ts
Normal file
133
src/webcomponents/TextInput/styles/getTextInputStyles.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
83
src/webcomponents/services/TextInputDataService.ts
Normal file
83
src/webcomponents/services/TextInputDataService.ts
Normal file
|
|
@ -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<string> {
|
||||
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<TextRecord>(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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/webcomponents/services/utils/validateTextInputArgs.ts
Normal file
25
src/webcomponents/services/utils/validateTextInputArgs.ts
Normal file
|
|
@ -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}'`);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue