Koyomi is dates for Phoenix: pickers for
LiveView forms, Koyomi.Rules for the days a picker allows (the same in
your changeset), and Ecto types for ranges. A grid uses it with no
dependency of Masu's:
- in a cell, a picker is an editor of your own (see Editors of your own);
- above the grid, a picker in a form of your LiveView filters it, through the grid's URL.
Install Koyomi
As its README says: {:koyomi, "~> 0.2"}, mix koyomi.install, the
Koyomi hook in your LiveSocket next to Masu, and its style sheet.
import { Masu } from "masu"
import { Koyomi } from "koyomi"
const liveSocket = new LiveSocket("/live", Socket, {
hooks: { ...colocatedHooks, Masu, Koyomi },
})Pickers in cells
A column of dates, times or datetimes keeps its type (it sorts, filters and formats as one); its editor is a picker of the same value:
<.data_grid id="bookings" source={Booking} repo={Repo} changeset={&Booking.changeset/2}>
<:col field={:guest} />
<:col field={:check_in} type={:date} editor={&check_in_editor/1} />
<:col field={:arrives_at} type={:time} editor={&arrival_editor/1} />
</.data_grid>import Koyomi.Components
@check_in_rules Koyomi.Rules.new(min: :today, disabled_weekdays: [7])
defp check_in_editor(assigns) do
assigns = assign(assigns, :rules, @check_in_rules)
~H"""
<.date_picker field={@field} label_id={@label_id} rules={@rules} />
"""
end
defp arrival_editor(assigns) do
~H"""
<.time_picker field={@field} label_id={@label_id} />
"""
end
def changeset(booking, attrs) do
booking
|> cast(attrs, [:guest, :check_in, :arrives_at])
|> Koyomi.Changeset.validate_date(:check_in, @check_in_rules)
endEnter (or a double click) on a cell opens a popover under it with the
picker, on the cell's value: its segments to type in or change with the
arrow keys, words ("next friday"), and its calendar. The picker posts the
form of the browser's own inputs (2027-03-15), which your changeset
casts as it casts a form's. Escape closes the picker's calendar first,
then the editor; Ctrl+Enter (⌘↩), Enter in the field, or a click away
saves.
The rules disable in the calendar what your changeset refuses, so a day the picker lets the person choose saves, and one it does not is refused with the changeset's error on the cell.
A :datetime column takes a datetime_picker, with the time_zone of
your users: it posts the wall clock time with the zone's offset, which
:utc_datetime casts.
A range in a cell
Koyomi.DateRange keeps a range of days in one field: a PostgreSQL
daterange, or, with storage: :map or :string, a map or a text for
SQLite and MySQL. Its values are Date.Ranges.
schema "bookings" do
field :stay, Koyomi.DateRange
end<:col field={:stay} editor={&stay_editor/1} width={260} sortable={false} filterable={false} />defp stay_editor(assigns) do
~H"""
<.date_range_picker field={@field} label_id={@label_id} months={2} />
"""
endThe picker posts cell[value][from] and cell[value][to], and
Koyomi.DateRange casts the map; Koyomi.Changeset.validate_range/3
checks its rules (the shortest and longest stay).
A grid shows a value as the browser gets it. Tell it how a range of days shows, once, in your app:
defimpl Masu.Wire, for: Date.Range do
def to_wire(range), do: "#{range.first} → #{range.last}"
endA range sorts and filters only as far as its database column does; keep
sortable={false} and filterable={false}, or give the grid its first
day in a column of its own.
A picker above the grid: filters
The grid's own filter menu filters a date column with the browser's date
inputs. For Koyomi's calendar, presets and words, put a picker above the
grid, in a form of your LiveView, and give the grid the filter through
its URL (url, with the Masu.Live hook):
<form id="placed" phx-change="placed">
<label id="placed-label">Placed</label>
<.date_range_picker name="placed" value={@placed} label_id="placed-label" relative months={2}
presets={[:today, {:last, 7, :days}, {:last, 30, :days}, :this_month]} />
</form>
<.data_grid id="orders" source={Order} repo={Repo} url>
<:col field={:number} />
<:col field={:placed_on} type={:date} />
</.data_grid>def handle_params(params, _uri, socket), do: {:noreply, assign(socket, placed: params["placed"])}
def handle_event("placed", %{"placed" => placed}, socket) do
params = %{"placed" => placed, "orders" => %{"filter" => placed_filter(placed)}}
{:noreply, push_patch(socket, to: ~p"/orders?#{params}")}
end
# Days picked, or a preset's key ("last_7_days"): the days it covers today.
defp placed_filter(%{"from" => from, "to" => to}) when from != "" and to != "",
do: %{"0" => %{"key" => "placed_on", "op" => "between", "value" => [from, to]}}
defp placed_filter(key) when is_binary(key) and key != "" do
case Koyomi.Presets.to_range(key) do
{:ok, range} -> placed_filter(%{"from" => to_string(range.first), "to" => to_string(range.last)})
:error -> ""
end
end
defp placed_filter(_cleared), do: ""The grid reads its view from the URL's params under its id, and a
between filter of the column runs in the database, as the person's own
filters do: the chip shows it, and a link opens it. The picker keeps its
own value in the URL too (placed=last_7_days), so that it stays a
preset in a shared link. The grid's filters in the URL are the picker's:
merge the person's other filters (params["orders"]["filter"]) if they
should stay.
Testing
view = open_editor(view, "bookings", 1, :check_in)
assert has_element?(view, ~s(#bookings-cell-editor [phx-hook="Koyomi"]))
assert :ok = edit(view, "bookings", [{1, :check_in, "2027-03-15"}])
assert :ok = edit(view, "bookings", [{1, :stay, %{"from" => "2027-03-15", "to" => "2027-03-20"}}])open_editor/4 renders the picker as Enter does; edit/4 saves what it
would post. Koyomi.Test picks values in a form of your LiveView, as for
the filter above.