Skip to content

Commit 395bdb8

Browse files
committed
feat: add native date picker prompt
1 parent fcd058f commit 395bdb8

8 files changed

Lines changed: 672 additions & 65 deletions

File tree

src/formatters/formatter.ts

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -472,24 +472,30 @@ export abstract class Formatter {
472472
variableName,
473473
{ type: "VDATE", dateFormat, defaultValue }
474474
);
475-
this.variables.set(variableName, dateInput);
476-
477-
if (!this.dateParser) throw new Error("Date parser is not available");
478-
479-
const aliasMap = settingsStore.getState().dateAliases;
480-
const normalizedInput = normalizeDateInput(dateInput, aliasMap);
481-
const parseAttempt = this.dateParser.parseDate(normalizedInput);
482-
483-
if (parseAttempt) {
484-
// Store the ISO string with a special prefix
485-
this.variables.set(
486-
variableName,
487-
`@date:${parseAttempt.moment.toISOString()}`,
488-
);
475+
if (dateInput?.startsWith("@date:")) {
476+
this.variables.set(variableName, dateInput);
489477
} else {
490-
throw new Error(
491-
`unable to parse date variable ${dateInput}`,
478+
if (!this.dateParser)
479+
throw new Error("Date parser is not available");
480+
481+
const aliasMap = settingsStore.getState().dateAliases;
482+
const normalizedInput = normalizeDateInput(
483+
dateInput,
484+
aliasMap,
492485
);
486+
const parseAttempt = this.dateParser.parseDate(normalizedInput);
487+
488+
if (parseAttempt) {
489+
// Store the ISO string with a special prefix
490+
this.variables.set(
491+
variableName,
492+
`@date:${parseAttempt.moment.toISOString()}`,
493+
);
494+
} else {
495+
throw new Error(
496+
`unable to parse date variable ${dateInput}`,
497+
);
498+
}
493499
}
494500
}
495501

src/formatters/vdate-default.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,16 @@ describe('VDATE Default Value Support', () => {
189189
expect(result).toBe("Test YYYY-MM-DD-formatted");
190190
});
191191

192+
it('should accept @date: values returned from prompts', async () => {
193+
const input = "Test {{VDATE:date,YYYY-MM-DD}}";
194+
formatter.setMockPromptValue("@date:2025-08-01T00:00:00.000Z");
195+
196+
const result = await formatter.testReplaceDateVariableInString(input);
197+
198+
expect(formatter.testDateParser.parseDate).not.toHaveBeenCalled();
199+
expect(result).toBe("Test YYYY-MM-DD-formatted");
200+
});
201+
192202
it('should use user input over default value', async () => {
193203
const input = "Test {{VDATE:date,YYYY-MM-DD|today}}";
194204
formatter.setMockPromptValue("tomorrow");

src/gui/GenericInputPrompt/GenericInputPrompt.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export default class GenericInputPrompt extends Modal {
1010
private resolvePromise: (input: string) => void;
1111
private rejectPromise: (reason?: unknown) => void;
1212
private didSubmit = false;
13-
private inputComponent: TextComponent;
13+
protected inputComponent: TextComponent;
1414
protected input: string;
1515
private readonly placeholder: string;
1616
private readonly draftHandler: InputPromptDraftHandler;
@@ -174,8 +174,13 @@ export default class GenericInputPrompt extends Modal {
174174
}
175175
};
176176

177+
protected transformInputOnSubmit(input: string): string {
178+
return input;
179+
}
180+
177181
private submit() {
178-
this.input = this.inputComponent?.inputEl?.value ?? this.input;
182+
const rawInput = this.inputComponent?.inputEl?.value ?? this.input;
183+
this.input = this.transformInputOnSubmit(rawInput);
179184
this.didSubmit = true;
180185

181186
this.close();

src/gui/VDateInputPrompt/VDateInputPrompt.ts

Lines changed: 144 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { App, Debouncer } from "obsidian";
22
import { TextComponent, debounce } from "obsidian";
33
import GenericInputPrompt from "../GenericInputPrompt/GenericInputPrompt";
4-
import { parseNaturalLanguageDate } from "../../utils/dateParser";
4+
import { createDatePicker, type DatePickerController } from "../date-picker/datePicker";
5+
import { formatISODate, parseNaturalLanguageDate } from "../../utils/dateParser";
56
import { settingsStore } from "../../settingsStore";
67
import {
78
formatDateAliasInline,
@@ -15,6 +16,9 @@ export default class VDateInputPrompt extends GenericInputPrompt {
1516
private currentInput: string;
1617
private isOpen = true;
1718
private defaultValue: string | undefined;
19+
private datePicker?: DatePickerController;
20+
private selectedIso?: string;
21+
private lastPickerDisplayValue?: string;
1822
private static readonly PREVIEW_PLACEHOLDER = "Preview will appear here";
1923

2024
public static Prompt(
@@ -43,29 +47,29 @@ export default class VDateInputPrompt extends GenericInputPrompt {
4347
) {
4448
// Pass the defaultValue to the parent so the input box is pre-filled
4549
super(app, header, placeholder, defaultValue ?? "");
46-
50+
51+
this.containerEl.addClass("qaDatePrompt");
4752
this.dateFormat = dateFormat || "YYYY-MM-DD";
4853
this.defaultValue = defaultValue;
4954
this.currentInput = defaultValue ?? "";
50-
55+
5156
// Create debounced preview update function (250ms delay, reset on each call)
5257
this.updatePreviewDebounced = debounce(
5358
this.updatePreview.bind(this),
5459
250,
5560
true // Reset timer on each call (standard debounce behavior)
5661
);
5762

58-
// Trigger initial preview update now that all fields are properly set
59-
if (this.defaultValue) {
60-
this.updatePreview();
61-
}
63+
this.updatePreview();
6264
}
6365

6466
protected createInputField(
6567
container: HTMLElement,
6668
placeholder?: string,
6769
value?: string
6870
) {
71+
container.addClass("qa-date-input");
72+
6973
// Create TextComponent directly to avoid duplicate onChange listeners
7074
const textComponent = new TextComponent(container);
7175

@@ -74,6 +78,7 @@ export default class VDateInputPrompt extends GenericInputPrompt {
7478
.setPlaceholder(placeholder ?? "")
7579
.setValue(value ?? "")
7680
.onChange((newValue) => {
81+
this.lastPickerDisplayValue = undefined;
7782
this.onInputChanged(newValue);
7883
this.currentInput = newValue;
7984
this.updatePreviewDebounced();
@@ -82,13 +87,30 @@ export default class VDateInputPrompt extends GenericInputPrompt {
8287

8388
// Initialize currentInput with the initial value (which should be defaultValue)
8489
this.currentInput = value ?? "";
85-
90+
91+
this.createDatePicker(container);
92+
8693
// Create preview element
8794
this.createPreviewElement(container);
88-
95+
8996
return textComponent;
9097
}
9198

99+
private createDatePicker(container: HTMLElement) {
100+
const pickerContainer = container.createDiv({
101+
cls: "qa-date-picker-container",
102+
});
103+
104+
this.datePicker = createDatePicker({
105+
container: pickerContainer,
106+
initialIso: this.selectedIso,
107+
onSelect: (iso) => {
108+
if (iso) this.applyPickerSelection(iso);
109+
else this.clearPickerSelection();
110+
},
111+
});
112+
}
113+
92114
private createPreviewElement(container: HTMLElement) {
93115
const previewContainer = container.createDiv("vdate-preview-container");
94116
previewContainer.style.marginTop = "0.5rem";
@@ -143,38 +165,109 @@ export default class VDateInputPrompt extends GenericInputPrompt {
143165
private updatePreview() {
144166
// Don't update if modal is closed
145167
if (!this.isOpen) return;
146-
168+
147169
const input = this.currentInput.trim();
148-
170+
149171
// If no input and we have a default, show preview for default
150172
if (!input && this.defaultValue) {
151-
this.renderPreview(this.defaultValue);
173+
this.renderPreviewFromInput(this.defaultValue);
152174
return;
153175
}
154-
176+
155177
if (!input) {
178+
this.selectedIso = undefined;
179+
this.lastPickerDisplayValue = undefined;
180+
this.syncPickerSelection();
156181
this.setPreviewText(VDateInputPrompt.PREVIEW_PLACEHOLDER, false);
157182
return;
158183
}
159-
184+
185+
if (input.startsWith("@date:")) {
186+
const iso = input.slice(6).trim();
187+
if (iso) {
188+
this.selectedIso = iso;
189+
this.lastPickerDisplayValue = undefined;
190+
this.syncPickerSelection(iso);
191+
this.renderPreviewFromIso(iso);
192+
return;
193+
}
194+
}
195+
196+
if (
197+
this.selectedIso &&
198+
this.lastPickerDisplayValue &&
199+
input === this.lastPickerDisplayValue
200+
) {
201+
this.syncPickerSelection(this.selectedIso, false);
202+
this.renderPreviewFromIso(this.selectedIso);
203+
return;
204+
}
205+
160206
// If input matches default value or regular input, render the preview
161-
this.renderPreview(input);
207+
this.renderPreviewFromInput(input);
162208
}
163209

164-
private renderPreview(value: string) {
210+
private formatIsoForInput(iso: string): string {
211+
const formatted = formatISODate(iso, this.dateFormat);
212+
if (formatted) return formatted;
213+
return iso.length >= 10 ? iso.slice(0, 10) : iso;
214+
}
215+
216+
private syncPickerSelection(iso?: string, updateView = true) {
217+
this.datePicker?.setSelectedIso(iso, { updateView });
218+
}
219+
220+
private applyPickerSelection(iso: string) {
221+
const displayValue = this.formatIsoForInput(iso);
222+
this.selectedIso = iso;
223+
this.lastPickerDisplayValue = displayValue;
224+
if (this.inputComponent?.inputEl) {
225+
this.inputComponent.inputEl.value = displayValue;
226+
}
227+
this.onInputChanged(displayValue);
228+
this.currentInput = displayValue;
229+
this.syncPickerSelection(iso);
230+
this.renderPreviewFromIso(iso);
231+
}
232+
233+
private clearPickerSelection() {
234+
if (this.inputComponent?.inputEl) {
235+
this.inputComponent.inputEl.value = "";
236+
}
237+
this.onInputChanged("");
238+
this.currentInput = "";
239+
this.selectedIso = undefined;
240+
this.lastPickerDisplayValue = undefined;
241+
this.syncPickerSelection();
242+
this.updatePreview();
243+
}
244+
245+
private renderPreviewFromIso(iso: string) {
246+
this.setPreviewText(this.formatIsoForInput(iso), false);
247+
}
248+
249+
private renderPreviewFromInput(value: string) {
165250
const parseResult = parseNaturalLanguageDate(value, this.dateFormat);
166-
167-
if (parseResult.isValid && parseResult.formatted) {
168-
this.setPreviewText(parseResult.formatted, false);
251+
252+
if (parseResult.isValid && parseResult.isoString) {
253+
this.selectedIso = parseResult.isoString;
254+
this.lastPickerDisplayValue = undefined;
255+
this.syncPickerSelection(parseResult.isoString);
256+
const formatted =
257+
parseResult.formatted ?? this.formatIsoForInput(parseResult.isoString);
258+
this.setPreviewText(formatted, false);
169259
} else {
260+
this.selectedIso = undefined;
261+
this.lastPickerDisplayValue = undefined;
262+
this.syncPickerSelection();
170263
const errorMessage = parseResult.error || "Unable to parse date";
171264
this.setPreviewText(errorMessage, true);
172265
}
173266
}
174267

175268
private setPreviewText(text: string, isError: boolean) {
176269
this.previewEl.textContent = text;
177-
270+
178271
if (isError) {
179272
this.previewEl.style.color = "var(--text-error)";
180273
} else {
@@ -186,18 +279,46 @@ export default class VDateInputPrompt extends GenericInputPrompt {
186279
super.onOpen();
187280
}
188281

282+
protected transformInputOnSubmit(input: string): string {
283+
const trimmed = input.trim();
284+
if (trimmed.startsWith("@date:")) return trimmed;
285+
if (
286+
this.selectedIso &&
287+
this.lastPickerDisplayValue &&
288+
trimmed === this.lastPickerDisplayValue
289+
) {
290+
return `@date:${this.selectedIso}`;
291+
}
292+
if (!trimmed && this.defaultValue) {
293+
const parsed = parseNaturalLanguageDate(
294+
this.defaultValue,
295+
this.dateFormat,
296+
);
297+
if (parsed.isValid && parsed.isoString) {
298+
return `@date:${parsed.isoString}`;
299+
}
300+
}
301+
if (trimmed) {
302+
const parsed = parseNaturalLanguageDate(trimmed, this.dateFormat);
303+
if (parsed.isValid && parsed.isoString) {
304+
return `@date:${parsed.isoString}`;
305+
}
306+
}
307+
return input;
308+
}
309+
189310
onClose() {
190311
// Prevent any pending debounced updates
191312
this.isOpen = false;
192-
313+
193314
// Cancel any pending debounced calls
194315
this.updatePreviewDebounced.cancel();
195-
316+
196317
// If input is empty and we have a default, use the default
197318
if (!this.input.trim() && this.defaultValue) {
198319
this.input = this.defaultValue;
199320
}
200-
321+
201322
super.onClose();
202323
}
203324
}

0 commit comments

Comments
 (0)