diff --git a/datetimepicker/datetimepicker.go b/datetimepicker/datetimepicker.go index c6251400..fa4435ac 100644 --- a/datetimepicker/datetimepicker.go +++ b/datetimepicker/datetimepicker.go @@ -28,31 +28,48 @@ var DefaultKeyMap = KeyMap{ Quit: key.NewBinding(key.WithKeys("ctrl+c")), } -// PositionType represents the current position (Date, Month, Year, Hour, or Minute) +// PositionType represents the current position (Date, Month, Year, Hour, or Minute). type PositionType int const ( + // Date represents the position type for selecting the date. Date PositionType = iota + + // Month represents the position type for selecting the month. Month + + // Year represents the position type for selecting the year. Year + + // Hour represents the position type for selecting the hour. Hour + + // Minute represents the position type for selecting the minute. Minute ) -// TimeFormat represents the time format (12-hour or 24-hour) +// TimeFormat represents the time format (12-hour or 24-hour). type TimeFormat int const ( + // Hour12 represents the 12-hour time format. Hour12 TimeFormat = iota + + // Hour24 represents the 24-hour time format. Hour24 ) -// PickerType represents the selection type (Date, Time, or Both) +// PickerType represents the selection type (Date, Time, or Both). type PickerType int const ( + // DateTime represents the picker type for selecting both date and time. DateTime PickerType = iota + + // DateOnly represents the picker type for selecting only the date. DateOnly + + // TimeOnly represents the picker type for selecting only the time. TimeOnly ) @@ -107,24 +124,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.Date = m.Date.AddDate(1, 0, 0) // Increase the year by 1 } if m.Pos == Hour { - m.Date = m.Date.Add(time.Hour) // Increase the minute by 1 + prevDate := m.Date + m.Date = m.Date.Add(time.Hour) // Increase the Hour by 1 + if prevDate.Day() != m.Date.Day() || prevDate.Month() != m.Date.Month() || prevDate.Year() != m.Date.Year() { + m.Date = m.Date.AddDate(0, 0, -1) // Decrease the date by 1 + } } if m.Pos == Minute { + prevDate := m.Date m.Date = m.Date.Add(time.Minute) // Increase the minute by 1 + if prevDate.Day() != m.Date.Day() || prevDate.Month() != m.Date.Month() || prevDate.Year() != m.Date.Year() { + m.Date = m.Date.AddDate(0, 0, -1) // Decrease the date by 1 + } } case key.Matches(msg, m.KeyMap.Decrement): if m.Pos == Date { - if m.Date.Year() <= 0 && m.Date.Month() <= time.January && m.Date.Day() <= 1 { - // Avoid negative year - } else { + if m.Date.After(time.Date(0, time.January, 1, 23, 59, 0, 0, time.UTC)) { // Date : 1 JAN 0000 (Avoid negative year) m.Date = m.Date.AddDate(0, 0, -1) // Decrease the date by 1 } } if m.Pos == Month { - if m.Date.Year() <= 0 && m.Date.Month() <= time.January { - // Avoid negative year - } else { + if m.Date.After(time.Date(0, time.January, 31, 23, 59, 0, 0, time.UTC)) { // Date : 31 JAN 0000 (Avoid negative year) m.Date = m.Date.AddDate(0, -1, 0) // Decrease the month by 1 } } @@ -134,10 +155,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } if m.Pos == Hour { - m.Date = m.Date.Add(-time.Hour) // Decrease the minute by 1 + prevDate := m.Date + m.Date = m.Date.Add(-time.Hour) // Decrease the Hour by 1 + if prevDate.Day() != m.Date.Day() || prevDate.Month() != m.Date.Month() || prevDate.Year() != m.Date.Year() { + m.Date = m.Date.AddDate(0, 0, 1) // Increase the date by 1 + } } if m.Pos == Minute { + prevDate := m.Date m.Date = m.Date.Add(-time.Minute) // Decrease the minute by 1 + if prevDate.Day() != m.Date.Day() || prevDate.Month() != m.Date.Month() || prevDate.Year() != m.Date.Year() { + m.Date = m.Date.AddDate(0, 0, 1) // Increase the date by 1 + } } case key.Matches(msg, m.KeyMap.Forward): @@ -191,12 +220,11 @@ func (m Model) dateView() string { yearStyle = m.TextStyle ) - switch m.Pos { - case Date: + if m.Pos == Date { dayStyle = m.CursorStyle - case Month: + } else if m.Pos == Month { monthStyle = m.CursorStyle - case Year: + } else if m.Pos == Year { yearStyle = m.CursorStyle } @@ -211,16 +239,16 @@ func (m Model) dateView() string { return dayStyle.Render(dayText) + " " + monthStyle.Render(month) + " " + yearStyle.Render(yearText) } -// formatTime formats the time based on the specified format (12-hour or 24-hour) +// formatTime formats the time based on the specified format (12-hour or 24-hour). func (m Model) timeView() string { var ( hourStyle = m.TextStyle minuteStyle = m.TextStyle ) - switch m.Pos { - case Hour: + + if m.Pos == Hour { hourStyle = m.CursorStyle - case Minute: + } else if m.Pos == Minute { minuteStyle = m.CursorStyle } @@ -239,13 +267,23 @@ func (m *Model) SetValue(date time.Time) { m.Date = date } -// SetValue sets the TimeFormat +// SetValue sets the TimeFormat. func (m *Model) SetTimeFormat(format TimeFormat) { + if format < 0 { + format = 0 + } else if format > 1 { + format = 1 + } m.TimeFormat = format } -// SetValue sets the TimeFormat +// SetPickerType sets the PickerType. func (m *Model) SetPickerType(pickerType PickerType) { + if pickerType < 0 { + pickerType = 0 + } else if pickerType > 2 { + pickerType = 2 + } m.PickerType = pickerType if pickerType == DateTime || pickerType == DateOnly { m.Pos = Date @@ -256,10 +294,19 @@ func (m *Model) SetPickerType(pickerType PickerType) { // Value returns the formatted date value as a string. func (m Model) Value() string { - return m.Date.Format("02 January 2006 03:04 PM") + if m.PickerType <= DateTime { + return m.Date.Format("02 January 2006 03:04 PM") + } else if m.PickerType >= TimeOnly { + if m.TimeFormat <= 0 { + return m.Date.Format("03:04 PM") + } else if m.TimeFormat >= 1 { + return m.Date.Format("15:04") + } + } + return m.Date.Format("02 January 2006") } -// bubbletea Init function +// bubbletea Init function. func (m Model) Init() tea.Cmd { return nil } diff --git a/datetimepicker/datetimepicker_test.go b/datetimepicker/datetimepicker_test.go new file mode 100644 index 00000000..7f7f642f --- /dev/null +++ b/datetimepicker/datetimepicker_test.go @@ -0,0 +1,294 @@ +package datetimepicker + +import ( + "testing" + "time" + "strings" + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +func TestNew(t *testing.T) { + picker := New() + view := picker.View() + + if picker.Pos != Date { + t.Errorf("Expected default position to be Date, got %v", picker.Pos) + } + if !strings.Contains(view, ">") { + t.Log(view) + t.Error("datetimepicker did not render the prompt") + } +} + + +func TestUpdate(t *testing.T) { + picker := New() + + testCases := []struct { + name string + keyMsgs []tea.Msg + expectedPos PositionType + expectedDate time.Time + pickerType PickerType + intializeDate time.Time + }{ + // Test key bindings. + { + name: "Left key press", + keyMsgs: []tea.Msg{ + tea.KeyMsg{Type: tea.KeyLeft, Alt: false, Runes: []rune{}}, + }, + expectedPos: Date, + pickerType: DateTime, + }, + { + name: "Right key press", + keyMsgs: []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: []rune{}}, + }, + expectedPos: Month, + pickerType: DateTime, + }, + { + name: "Forward key press for DateOnly picker", + keyMsgs: []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: []rune{}}, + }, + expectedPos: Year, + pickerType: DateOnly, + }, + { + name: "Backward key press for TimeOnly picker", + keyMsgs: []tea.Msg{ + tea.KeyMsg{Type: tea.KeyLeft, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyLeft, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyLeft, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyLeft, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyLeft, Alt: false, Runes: []rune{}}, + }, + expectedPos: Hour, + pickerType: TimeOnly, + }, + // Test Increment/Decrement. + { + name: "Increment Test (Up key)", + keyMsgs: []tea.Msg{ + tea.KeyMsg{Type: tea.KeyUp, Alt: false, Runes: []rune{}}, + }, + expectedDate: picker.Date.AddDate(0, 0, 1), + pickerType: DateTime, + }, + { + name: "Decrement Test : Decrement the month by 1", + keyMsgs: []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyDown, Alt: false, Runes: []rune{}}, + }, + expectedDate: time.Date(2024, time.January, 1, 12, 0, 0, 0, time.UTC), + pickerType: DateTime, + expectedPos: Month, + intializeDate: time.Date(2024, time.February, 1, 12, 0, 0, 0, time.UTC), + }, + { + name: "Avoid negative years (by decrementing month)", + keyMsgs: []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyDown, Alt: false, Runes: []rune{}}, + }, + expectedDate: time.Date(0, time.January, 1, 23, 59, 0, 0, time.UTC), + pickerType: DateTime, + intializeDate: time.Date(0, time.January, 1, 23, 59, 0, 0, time.UTC), + expectedPos: Month, + }, + { + name: "Avoid negative years (by decrementing Date)", + keyMsgs: []tea.Msg{ + tea.KeyMsg{Type: tea.KeyDown, Alt: false, Runes: []rune{}}, + }, + expectedDate: time.Date(0, time.January, 1, 23, 59, 0, 0, time.UTC), + pickerType: DateTime, + intializeDate: time.Date(0, time.January, 1, 23, 59, 0, 0, time.UTC), + expectedPos: Date, + }, + { + name: "Avoid negative years (by decrementing Year)", + keyMsgs: []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyDown, Alt: false, Runes: []rune{}}, + }, + expectedDate: time.Date(0, time.January, 1, 23, 59, 0, 0, time.UTC), + pickerType: DateTime, + intializeDate: time.Date(0, time.January, 1, 23, 59, 0, 0, time.UTC), + expectedPos: Year, + }, + { + name: "Avoid negative years (by decrementing Hour)", + keyMsgs: []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyDown, Alt: false, Runes: []rune{}}, + }, + expectedDate: time.Date(0, time.January, 1, 23, 59, 0, 0, time.UTC), + pickerType: DateTime, + intializeDate: time.Date(0, time.January, 1, 0, 59, 0, 0, time.UTC), + expectedPos: Hour, + }, + { + name: "Avoid negative years (by decrementing Minute)", + keyMsgs: []tea.Msg{ + tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyRight, Alt: false, Runes: []rune{}}, + tea.KeyMsg{Type: tea.KeyDown, Alt: false, Runes: []rune{}}, + }, + expectedDate: time.Date(0, time.January, 1, 23, 59, 0, 0, time.UTC), + pickerType: DateTime, + intializeDate: time.Date(0, time.January, 1, 0, 0, 0, 0, time.UTC), + expectedPos: Minute, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + p := picker + p.SetPickerType(tc.pickerType) + if !tc.intializeDate.IsZero() { + p.SetValue(tc.intializeDate) + } + for _, msg := range tc.keyMsgs { + pModel, _ := p.Update(msg) + p = pModel.(Model) + } + + if p.Pos != tc.expectedPos { + t.Errorf("Expected position %v after %s, got %v", tc.expectedPos, tc.name, p.Pos) + } + + if !tc.expectedDate.IsZero() && p.Date != tc.expectedDate { + t.Errorf("Expected date %v after %s, got %v", tc.expectedDate, tc.name, p.Date) + } + }) + } +} + +func TestSetValue(t *testing.T) { + picker := New() + + // Set date value and check if it's correctly set. + newDate := time.Date(2024, time.February, 1, 12, 0, 0, 0, time.UTC) + picker.SetValue(newDate) + if !picker.Date.Equal(newDate) { + t.Error("Expected date value to be set to", newDate) + } +} + +func TestSetTimeFormat(t *testing.T) { + picker := New() + + // Set time format to 24-hour and check if it's correctly set. + picker.SetTimeFormat(Hour24) + if picker.TimeFormat != Hour24 { + t.Error("Expected time format to be set to 24-hour") + } + + // Should auto handle if timeFormat is out of defined enum. + picker.SetTimeFormat(-1) + if picker.TimeFormat != Hour12 { + t.Error("Expected time format to be set to 12-hour") + } + + picker.SetTimeFormat(2) + if picker.TimeFormat != Hour24 { + t.Error("Expected time format to be set to 12-hour") + } + +} + +func TestSetPickerType(t *testing.T) { + picker := New() + + // Test 1: Set picker type to TimeOnly and check if it's correctly set. + picker.SetPickerType(TimeOnly) + if picker.PickerType != TimeOnly { + t.Error("Expected picker type to be set to TimeOnly") + } + if picker.Pos != Hour { + t.Error("Expected Pos to be set to Hour") + } + + // Test 2: + picker.SetPickerType(DateOnly) + if picker.PickerType != DateOnly { + t.Error("Expected picker type to be set to DateOnly") + } + if picker.Pos != Date { + t.Error("Expected Pos to be set to Date") + } + + // Test 3: + picker.SetPickerType(DateTime) + if picker.PickerType != DateTime { + t.Error("Expected picker type to be set to DateTime") + } + if picker.Pos != Date { + t.Error("Expected Pos to be set to Date") + } +} + +func TestValue(t *testing.T) { + picker := New() + + // Test value formatting for different picker types and time formats. + // Test 1: (DateTime pickerType). + inputTime := time.Date(2024, time.February, 1, 12, 0, 0, 0, time.UTC) + picker.SetValue(inputTime) + + if err := validateValue(picker, DateTime, inputTime); err != nil { + t.Error(err) + } + + // Test 2: (DateOnly pickerType). + picker.SetPickerType(DateOnly) + + if err := validateValue(picker, DateOnly, inputTime); err != nil { + t.Error(err) + } + + // Test 3: (TimeOnly pickerType). + picker.SetPickerType(TimeOnly) + + if err := validateValue(picker, TimeOnly, inputTime); err != nil { + t.Error(err) + } +} + +func validateValue(m Model, pickerType PickerType, inputTime time.Time) error { + expectedValue := "" + if pickerType == DateTime { + expectedValue = inputTime.Format("02 January 2006 03:04 PM") + } else if pickerType == DateOnly { + expectedValue = inputTime.Format("02 January 2006") + } else { + // TimeOnly. + if m.TimeFormat == Hour12 { + expectedValue = inputTime.Format("03:04 PM") + } else { // Hour24. + expectedValue = inputTime.Format("15:04") + } + } + + if val := m.Value(); val != expectedValue { + return fmt.Errorf("Expected value %s, got %s", expectedValue, val) + } + return nil +}