All notable changes to Masu are documented in this file.

The format follows Keep a Changelog, and Masu follows Semantic Versioning.

[Unreleased]

Added

  • Masu.Components.data_grid/1: a virtualized data grid for LiveView, with a :col slot for each column and cells rendered by HEEx.
  • Sources: a list of rows (Masu.Source.List) and an Ecto query or schema (Masu.Source.Ecto), which sorts, filters, searches and counts in the database. Masu.Source for your own.
  • Sorting by several columns, filters (Masu.Filter) and a search box.
  • Columns that resize, and fit their content on a double click.
  • Keyboard navigation of the cells, a selected range, and copy of the range as tab-separated text that spreadsheets paste.
  • Cells formatted for the locale: numbers, decimals, dates and times.
  • Masu.Test, to read and query a grid in Phoenix.LiveViewTest.
  • A filter menu on each column (the funnel of its header, or Alt+↓): the filters of the column's type, an enum's values with their counts (Masu.Source.values/5: a list in the LiveView, one GROUP BY for Ecto), and the filters as chips above the grid. Masu.Test.values/4.
  • Editing: changeset (or update) on the grid, editable={false} on a column. A cell edits on a key typed, Enter, F2 or a double click, with an editor for its type; the edits of a row are saved together, through the changeset and the repo (or applied to a row of a list), and the changeset's errors go on their cells. Paste of tab-separated text, fill down, Delete, undo and redo. A row is found again through the grid's own query (Masu.Source.fetch/3). Masu.Test.edit/3.
  • Read-only rows and cells by their state: row_editable on the grid, and editable as a function of the row on a column. The server refuses an edit of a read-only cell (with the row it finds again) and saves the row's other edits; the browser gets each row's read-only cells, which open no editor and have aria-readonly.
  • Edits of a cell that changed meanwhile are refused: each edit carries the value the person saw (when the editor opened), compared on the server by the column's type, a compare-and-swap of each cell; the cell then shows its value now. An Ecto source compares and saves each row in a transaction that locks it (SELECT ... FOR UPDATE on PostgreSQL and MySQL), and takes optimistic_lock/3's Ecto.StaleEntryError as the same refusal. Masu.Test.edit/3 takes {id, key, value, before}.
  • on_save: a function called with the saved rows, in the LiveView's process, for an audit, a flash or the LiveView's own assigns.
  • Collaboration (Masu.Presence): presence and user on a grid with live. The others who have the grid open show as avatars (a :person slot draws them), each one's cell as a line in their color with their name (by row id and column key, whatever each person's order and filters), a cell someone types in as "Ada is typing" and locked for the others, and the rows they save flash in their color. configure/1 of the bundle draws the labels; CSS for the rest.
  • Following a person: a click on their avatar shows their view (order, filters, search) and keeps their place in it on the screen; Stop brings back one's own view and place, and taking over (scrolling, clicking, typing) keeps theirs, with "Back to your view". share_view={false} keeps a person's view to themselves; whoever follows them stays in their own order, and the grid finds the person's row in it (Masu.Source.locate/3, a row_number() window for Ecto; Masu.Test.place/3). Each person's selected range shows to the others in their color. A refused edit names who changed the cell: "Changed by Ada meanwhile."
  • Export (Masu.Export): export adds an Export menu, CSV and XLSX (written with no library, zipped as it is written) of every row of the query, downloaded by the browser from the route masu_exports mounts (Masu.Router) as the rows are read, with a link good once, for five minutes; nothing of the file goes over the LiveView's socket. Masu.Live.export/1 gives an app the export to write or stream anywhere. Selection: selectable boxes, Shift ranges, Ctrl and Space, and every row of the query at once, which Masu.Live.selected/1 gives as an Ecto.Query; on_select. An Ecto source reads the next block by key, not by OFFSET. Masu.Test.export/4, export_url/3, select/4, select_all/3 and selection/2.
  • Theming to match an app's own look: tokens for the grid's box (--masu-grid-border, -radius, -shadow), the header (its height, type, padding, border or shadow, hover), the rows (background, every second row's, the line under each) and a cell's hover; .masu-row-even on every second row; a row's hover over its own background; density (:compact, :normal, :comfortable); a dark palette under a dark class or data-theme="dark"; Tailwind in the Theming guide, and Lotus Web's results table as its worked example, compared pixel for pixel in e2e.
  • Slow sources: load={:async} reads a source in a task (a new query's first rows and count, a block, a page, a count): the LiveView answers meanwhile, the grid says "Running…" with Stop and keeps its rows, timeout stops a long read, and a source's error (a raise, or {:error, reason} from rows/3, Masu.Source.Error) shows in the grid with Run again. load={:inline}, the default, reads as before.
  • Controlled grids: with on_query, the LiveView runs its own queries; the grid shows the rows it is given (with total and loading), and reports the person's sort, filter, search, page and view. One sort at a time with multi_sort={false}. Masu.Test waits for tasks.
  • Columns not known in advance, as a query tool's results: rows as lists of values (field={2} reads a place of a list), type={:auto} (the type of the values of the first block of rows), columns on a grid with no :col slots, and Masu.Source.columns/1. Rows of other columns keep the person's view of the columns there still and name the rest (Masu.Query's sorts, filters and groups too); an order the person did not change follows the new columns. Masu.Column.infer/1.
  • The filters a source runs: the optional Masu.Source.filters/2 gives the operators a source filters a column by. The filter menu, the chips, the menu of a cell, a view's params and Masu.Test.filter/3 offer only those, and the server refuses the others (a link's or a saved view's are dropped and named). Masu runs "between" as "at least" and "at most" for a source without it; an enum without "is one of" filters by one of its values. Masu.Filter.ops/1. The Sources guide maps each operator to SQL.
  • Menus of your own (Masu.Action): :header_action items in each column's header menu, and a menu on each cell, opened by a right click, the context-menu key or Shift+F10, as an ARIA menu. cell_menu gives it copy, copy with headers, filter by the value, exclude it, empty and not empty, and sort; :cell_action adds the app's items. An item runs a function (on_action), sends the LiveView {Masu.Action, name, context} (event), runs a JS command in the browser (js, after a masu:action event), or opens a panel of its content under the header or the cell; for some columns (columns, types, show). The context has the column, and the cell's row and value read from the source, and the range it is in. The server runs only the items the grid offers. Masu.Test.actions/4, header_action/4, cell_action/6 and close_panel/2.
  • Row ids: row_id={:position} knows rows by their place in the query, for a read-only grid of rows with no id; a grid that needs no ids does so for a row without one. A grid whose features need ids that stay (editing, new and deleted rows, selection, live rows, presence, details, trees) refuses :position, and a row without an id, saying which.
  • Reads: block_size (100) is the rows of one read as the person scrolls, and max_block (500) the most of one read; a page (paging={:pages}) is one read, up to max_block, and the next page is not read ahead. The first render reads one block, which the browser keeps.
  • Counts and pages: count={:deferred} counts after the first rows, in a task; count={:none} counts when the person goes to the end. A source's count/1 may give {:at_least, n} or :unknown, and rows/3 {rows, count}: the grid scrolls over the rows it read and grows, aria-rowcount="-1", "1,000+ rows". An Ecto source counts only when asked. paging={:pages} and page_size: a page at a time, with a pager, the page in the view and the URL. Masu.Test.count/2, ask_count/2, page/3.
  • New and deleted rows: create (or changeset with struct) adds a "+ New row" row, Ctrl+Enter and Insert; a new row is a draft until the person leaves it, with its errors on its cells; a paste past the last row offers to add the rest. deletable (Repo.delete, or out of a list; a function of the row says which) or delete deletes rows with Ctrl+Delete or from the selection bar, after a question; Ctrl+Z brings them back (restore for a grid with delete). New and deleted rows go to the grid's topic; on_delete. Masu.Test.add_rows/3, delete_rows/3, restore_rows/3.
  • Editors: an enum edits with a list of its values (values, or a schema's Ecto.Enum field's), which narrows as the person types; decimals on a number column; a value the server would refuse (text in a number, a day that is not one) is caught in the browser.
  • Groups and totals (Masu.Group): aggregate on a column (:sum, :avg, :min, :max, :count, :count_distinct, or a function of a list's rows) and a row of totals at the bottom; group_by and "Group by" in the header menu, with a chip, as part of the view. Groups open on groups and rows as a tree does, with their counts and totals, and sort by value or by a total. An Ecto source groups and totals in the database, a GROUP BY for each level that shows, and reads an open group's rows a block at a time. Masu.Test.group/3, groups/3 and totals/2. A filter on a hidden column keeps its chip.
  • Trees and details (Masu.Tree): tree={:parent_id} shows rows in a tree (a treegrid), opened and closed with their arrow, →, ← and *, as one list of the rows that are open: a tree of twenty thousand rows scrolls as a list does. Siblings sort among themselves, and a filter keeps its matches and their ancestors. Both sources take it; the Ecto source reads the children of a row when it opens. A :detail slot opens a row on anything rendered by the server under it (another data_grid too), with Alt+→/←, Alt+↓ into it and Escape out. Masu.Test.nodes/3, expand/3, collapse/3, expand_siblings/3 and detail/4.
  • Layout and views: pinned={:start | :end} and hidden on a :col; columns that move by dragging their header (or Alt+Shift+←/→), and a columns menu (columns_menu) to show, hide, pin and move them. A person's view (Masu.View: order, filters, search and Masu.Layout) goes into the URL with url, and into a store with views (Masu.Views: Masu.Views.Ecto with mix masu.gen.views, and Masu.Views.Memory): their last view comes back, and a views menu saves named views, shared or not. A view's parts that no longer apply are dropped, and the grid says so. Masu.Test.view/2, apply_view/3 and layout/3.

  • Live rows: live={{pubsub, topic}}, the Masu.Live hook, and Masu.Live.broadcast_upsert/3 and broadcast_delete/3. A grid takes the changes of its topic into the person's query, a batch at a time; the browser keeps the scroll and the rows on the screen until the new ones come, and flashes the changed rows.