From b18c1579bd3d4a2fea247baf655ade6e6bd264ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Thu, 17 Jun 2021 01:02:27 +0200 Subject: [PATCH] Add dynamic table output (#356) * Add dynamic table output * Support table name --- assets/css/components.css | 338 +++++++++--------- assets/css/utilities.css | 27 +- lib/livebook/notebook/cell/elixir.ex | 2 + .../live/output/table_dynamic_live.ex | 202 +++++++++++ .../live/session_live/cell_component.ex | 51 +-- 5 files changed, 410 insertions(+), 210 deletions(-) create mode 100644 lib/livebook_web/live/output/table_dynamic_live.ex diff --git a/assets/css/components.css b/assets/css/components.css index 429253692..efd63c08c 100644 --- a/assets/css/components.css +++ b/assets/css/components.css @@ -1,170 +1,172 @@ -/* Buttons */ +@layer components { + /* Buttons */ -.button { - @apply px-5 py-2 rounded-lg border border-transparent font-medium text-sm; -} - -.button-blue { - @apply border-transparent bg-blue-600 text-white hover:bg-blue-700 focus:bg-blue-700; -} - -.button-red { - @apply border-transparent bg-red-600 text-white hover:bg-red-700 focus:bg-red-700; -} - -.button-gray { - @apply border-gray-200 bg-gray-100 text-gray-600 hover:bg-gray-200 focus:bg-gray-200; -} - -.button-outlined-blue { - @apply bg-blue-50 border-blue-600 text-blue-600 hover:bg-blue-100 focus:bg-blue-100; -} - -.button-outlined-red { - @apply bg-red-50 border-red-600 text-red-600 hover:bg-red-100 focus:bg-red-100; -} - -.button-outlined-gray { - @apply bg-white border-gray-300 text-gray-600 hover:bg-gray-100 focus:bg-gray-100; -} - -.button:disabled { - @apply cursor-default pointer-events-none border-transparent bg-gray-100 text-gray-400; -} - -.button-small { - @apply px-2 py-1 bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100 focus:bg-gray-100; -} - -.button-square-icon { - @apply p-2 flex items-center justify-center; -} - -.button-square-icon i { - @apply text-xl leading-none; -} - -.choice-button { - @apply px-5 py-2 rounded-lg border text-gray-700 bg-white border-gray-200; -} - -.choice-button.active { - @apply bg-blue-100 border-blue-600; -} - -.icon-button { - @apply p-1 flex items-center justify-center text-gray-400 hover:text-gray-800; -} - -.icon-button:focus { - @apply rounded-full bg-gray-100; -} - -.icon-button i { - line-height: 1; -} - -.icon-outlined-button { - @apply rounded-full border-2; -} - -/* Form fields */ - -.input { - @apply w-full px-3 py-2 bg-gray-50 text-sm border border-gray-200 rounded-lg placeholder-gray-400 text-gray-600; -} - -.input--error { - @apply bg-red-50 border-red-600 text-red-600; -} - -.input-label { - @apply mb-0.5 text-sm text-gray-800 font-medium; -} - -.input-error { - @apply mt-2 text-red-600 italic text-xs; -} - -.switch-button { - @apply relative inline-block w-14 h-7 mr-2 select-none transition; -} - -.switch-button__checkbox { - @apply appearance-none absolute block w-7 h-7 rounded-full bg-gray-400 border-[5px] border-gray-100 cursor-pointer transition-all duration-300; -} - -.switch-button__bg { - @apply block h-full w-full rounded-full bg-gray-100 cursor-pointer transition-all duration-300; -} - -.switch-button__checkbox:checked { - @apply bg-white border-blue-600; - transform: translateX(100%); -} - -.switch-button__checkbox:checked + .switch-button__bg { - @apply bg-blue-600; -} - -.radio-base { - appearance: none; - width: 20px; - height: 20px; - cursor: pointer; - background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='10' cy='10' r='9.5' stroke='%23CAD5E0' fill='white' /%3e%3c/svg%3e"); - background-size: 100% 100%; - background-position: center; - background-repeat: no-repeat; -} - -.radio-base:checked { - background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='10' cy='10' r='9.5' stroke='%233E64FF' fill='white' /%3e%3ccircle cx='10' cy='10' r='6' fill='%233E64FF' /%3e%3c/svg%3e"); -} - -/* Custom scrollbars */ - -.tiny-scrollbar::-webkit-scrollbar { - width: 0.4rem; - height: 0.4rem; -} - -.tiny-scrollbar::-webkit-scrollbar-thumb { - border-radius: 0.25rem; - @apply bg-gray-400; -} - -.tiny-scrollbar::-webkit-scrollbar-track { - @apply bg-gray-100; -} - -/* Tabs */ - -.tabs { - @apply w-full flex; -} - -.tabs .tab { - @apply flex items-center space-x-2 px-3 py-2 border-b-2 text-gray-400 border-gray-100; -} - -.tabs .tab.active { - @apply text-blue-600 border-blue-600; -} - -/* Toggleable menu */ - -.menu { - @apply absolute right-0 z-20 rounded-lg bg-white flex flex-col py-2; - box-shadow: 0px 15px 99px rgba(13, 24, 41, 0.15); -} - -.menu__item { - @apply flex space-x-3 px-5 py-2 items-center hover:bg-gray-50 whitespace-nowrap; -} - -/* Boxes */ - -.error-box { - @apply mb-3 rounded-lg px-4 py-2 bg-red-100 text-red-400 font-medium; + .button { + @apply px-5 py-2 rounded-lg border border-transparent font-medium text-sm; + } + + .button-blue { + @apply border-transparent bg-blue-600 text-white hover:bg-blue-700 focus:bg-blue-700; + } + + .button-red { + @apply border-transparent bg-red-600 text-white hover:bg-red-700 focus:bg-red-700; + } + + .button-gray { + @apply border-gray-200 bg-gray-100 text-gray-600 hover:bg-gray-200 focus:bg-gray-200; + } + + .button-outlined-blue { + @apply bg-blue-50 border-blue-600 text-blue-600 hover:bg-blue-100 focus:bg-blue-100; + } + + .button-outlined-red { + @apply bg-red-50 border-red-600 text-red-600 hover:bg-red-100 focus:bg-red-100; + } + + .button-outlined-gray { + @apply bg-white border-gray-300 text-gray-600 hover:bg-gray-100 focus:bg-gray-100; + } + + .button:disabled { + @apply cursor-default pointer-events-none border-transparent bg-gray-100 text-gray-400; + } + + .button-small { + @apply px-2 py-1 bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100 focus:bg-gray-100; + } + + .button-square-icon { + @apply p-2 flex items-center justify-center; + } + + .button-square-icon i { + @apply text-xl leading-none; + } + + .choice-button { + @apply px-5 py-2 rounded-lg border text-gray-700 bg-white border-gray-200; + } + + .choice-button.active { + @apply bg-blue-100 border-blue-600; + } + + .icon-button { + @apply p-1 flex items-center justify-center text-gray-400 hover:text-gray-800; + } + + .icon-button:focus { + @apply rounded-full bg-gray-100; + } + + .icon-button i { + line-height: 1; + } + + .icon-outlined-button { + @apply rounded-full border-2; + } + + /* Form fields */ + + .input { + @apply w-full px-3 py-2 bg-gray-50 text-sm border border-gray-200 rounded-lg placeholder-gray-400 text-gray-600; + } + + .input--error { + @apply bg-red-50 border-red-600 text-red-600; + } + + .input-label { + @apply mb-0.5 text-sm text-gray-800 font-medium; + } + + .input-error { + @apply mt-2 text-red-600 italic text-xs; + } + + .switch-button { + @apply relative inline-block w-14 h-7 mr-2 select-none transition; + } + + .switch-button__checkbox { + @apply appearance-none absolute block w-7 h-7 rounded-full bg-gray-400 border-[5px] border-gray-100 cursor-pointer transition-all duration-300; + } + + .switch-button__bg { + @apply block h-full w-full rounded-full bg-gray-100 cursor-pointer transition-all duration-300; + } + + .switch-button__checkbox:checked { + @apply bg-white border-blue-600; + transform: translateX(100%); + } + + .switch-button__checkbox:checked + .switch-button__bg { + @apply bg-blue-600; + } + + .radio-base { + appearance: none; + width: 20px; + height: 20px; + cursor: pointer; + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='10' cy='10' r='9.5' stroke='%23CAD5E0' fill='white' /%3e%3c/svg%3e"); + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; + } + + .radio-base:checked { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='10' cy='10' r='9.5' stroke='%233E64FF' fill='white' /%3e%3ccircle cx='10' cy='10' r='6' fill='%233E64FF' /%3e%3c/svg%3e"); + } + + /* Custom scrollbars */ + + .tiny-scrollbar::-webkit-scrollbar { + width: 0.4rem; + height: 0.4rem; + } + + .tiny-scrollbar::-webkit-scrollbar-thumb { + border-radius: 0.25rem; + @apply bg-gray-400; + } + + .tiny-scrollbar::-webkit-scrollbar-track { + @apply bg-gray-100; + } + + /* Tabs */ + + .tabs { + @apply w-full flex; + } + + .tabs .tab { + @apply flex items-center space-x-2 px-3 py-2 border-b-2 text-gray-400 border-gray-100; + } + + .tabs .tab.active { + @apply text-blue-600 border-blue-600; + } + + /* Toggleable menu */ + + .menu { + @apply absolute right-0 z-20 rounded-lg bg-white flex flex-col py-2; + box-shadow: 0px 15px 99px rgba(13, 24, 41, 0.15); + } + + .menu__item { + @apply flex space-x-3 px-5 py-2 items-center hover:bg-gray-50 whitespace-nowrap; + } + + /* Boxes */ + + .error-box { + @apply mb-3 rounded-lg px-4 py-2 bg-red-100 text-red-400 font-medium; + } } diff --git a/assets/css/utilities.css b/assets/css/utilities.css index 962864f4c..5dc4cf37b 100644 --- a/assets/css/utilities.css +++ b/assets/css/utilities.css @@ -1,14 +1,21 @@ -/* A set of reusable classes */ +@layer utilities { + /* A set of reusable classes */ -.bg-editor { - background-color: #282c34; -} + .bg-editor { + background-color: #282c34; + } -.text-editor { - color: #abb2bf; -} + .text-editor { + color: #abb2bf; + } -.font-editor { - font-family: "JetBrains Mono", "Droid Sans Mono", "monospace"; - font-size: 14px; + .font-editor { + font-family: "JetBrains Mono", "Droid Sans Mono", "monospace"; + font-size: 14px; + } + + .shadow-xl-center { + box-shadow: 0 0 25px -5px rgba(0, 0, 0, 0.1), + 0 0 10px -5px rgba(0, 0, 0, 0.04); + } } diff --git a/lib/livebook/notebook/cell/elixir.ex b/lib/livebook/notebook/cell/elixir.ex index fb9b9920d..e701f2b03 100644 --- a/lib/livebook/notebook/cell/elixir.ex +++ b/lib/livebook/notebook/cell/elixir.ex @@ -31,6 +31,8 @@ defmodule Livebook.Notebook.Cell.Elixir do | {:vega_lite_static, spec :: map()} # Vega-Lite graphic with dynamic data | {:vega_lite_dynamic, widget_process :: pid()} + # Interactive data table + | {:table_dynamic, widget_process :: pid()} # Internal output format for errors | {:error, message :: binary()} diff --git a/lib/livebook_web/live/output/table_dynamic_live.ex b/lib/livebook_web/live/output/table_dynamic_live.ex new file mode 100644 index 000000000..63fb37d1f --- /dev/null +++ b/lib/livebook_web/live/output/table_dynamic_live.ex @@ -0,0 +1,202 @@ +defmodule LivebookWeb.Output.TableDynamicLive do + use LivebookWeb, :live_view + + @limit 10 + @loading_delay_ms 100 + + @impl true + def mount(_params, %{"pid" => pid, "id" => id}, socket) do + send(pid, {:connect, self()}) + + {:ok, + assign(socket, + id: id, + pid: pid, + loading: true, + show_loading_timer: nil, + # Data specification + page: 1, + limit: @limit, + order_by: nil, + order: :asc, + # Fetched data + name: "Table", + features: [], + columns: [], + rows: [], + total_rows: 0 + )} + end + + @impl true + def render(%{loading: true} = assigns) do + ~L""" +
+
+
+
+
+
+
+ """ + end + + def render(assigns) do + ~L""" +
+

+ <%= @name %> +

+
+ +
+ + <%= tag :button, class: "icon-button", + phx_click: "refetch" %> + <%= remix_icon("refresh-line", class: "text-xl") %> + + +
+ + <%= if :pagination in @features and @total_rows > 0 do %> +
+ <%= tag :button, + class: "flex items-center font-medium text-sm text-gray-400 hover:text-gray-800 disabled:pointer-events-none disabled:text-gray-300", + disabled: @page == 1, + phx_click: "prev" %> + <%= remix_icon("arrow-left-s-line", class: "text-xl") %> + Prev + +
+ <%= @page %> of <%= max_page(@total_rows, @limit) %> +
+ <%= tag :button, + class: "flex items-center font-medium text-sm text-gray-400 hover:text-gray-800 disabled:pointer-events-none disabled:text-gray-300", + disabled: @page == max_page(@total_rows, @limit), + phx_click: "next" %> + Next + <%= remix_icon("arrow-right-s-line", class: "text-xl") %> + +
+ <% end %> +
+ <%= if @columns == [] do %> + +

+ No data +

+ <% else %> + +
+ + + + <%= for {column, idx} <- Enum.with_index(@columns) do %> + + <% end %> + + + + <%= for row <- @rows do %> + + <%= for column <- @columns do %> + + <% end %> + + <% end %> + +
" + phx-click="column_click" + phx-value-column_idx="<%= idx %>"> +
+ <%= column.label %> + <%= tag :span, class: unless(@order_by == column.key, do: "invisible") %> + <%= remix_icon(order_icon(@order), class: "text-xl align-middle leading-none") %> + +
+
+ <%= row.fields[column.key] %> +
+
+ <% end %> + """ + end + + defp order_icon(:asc), do: "arrow-up-s-line" + defp order_icon(:desc), do: "arrow-down-s-line" + + defp max_page(total_rows, limit) do + ceil(total_rows / limit) + end + + @impl true + def handle_event("refetch", %{}, socket) do + {:noreply, request_rows(socket)} + end + + def handle_event("prev", %{}, socket) do + {:noreply, assign(socket, :page, socket.assigns.page - 1) |> request_rows()} + end + + def handle_event("next", %{}, socket) do + {:noreply, assign(socket, :page, socket.assigns.page + 1) |> request_rows()} + end + + def handle_event("column_click", %{"column_idx" => idx}, socket) do + idx = String.to_integer(idx) + %{key: key} = Enum.at(socket.assigns.columns, idx) + + {order_by, order} = + case {socket.assigns.order_by, socket.assigns.order} do + {^key, :asc} -> {key, :desc} + {^key, :desc} -> {nil, :asc} + _ -> {key, :asc} + end + + {:noreply, assign(socket, order_by: order_by, order: order) |> request_rows()} + end + + @impl true + def handle_info({:connect_reply, %{name: name, columns: columns, features: features}}, socket) do + {:noreply, assign(socket, name: name, columns: columns, features: features) |> request_rows()} + end + + def handle_info({:rows, %{rows: rows, total_rows: total_rows, columns: columns}}, socket) do + columns = + case columns do + :initial -> socket.assigns.columns + columns when is_list(columns) -> columns + end + + if socket.assigns.show_loading_timer do + Process.cancel_timer(socket.assigns.show_loading_timer) + end + + {:noreply, + assign(socket, + loading: false, + show_loading_timer: nil, + columns: columns, + rows: rows, + total_rows: total_rows + )} + end + + def handle_info(:show_loading, socket) do + {:noreply, assign(socket, loading: true, show_loading_timer: nil)} + end + + defp request_rows(socket) do + rows_spec = %{ + offset: (socket.assigns.page - 1) * socket.assigns.limit, + limit: socket.assigns.limit, + order_by: socket.assigns.order_by, + order: socket.assigns.order + } + + send(socket.assigns.pid, {:get_rows, self(), rows_spec}) + + show_loading_timer = Process.send_after(self(), :show_loading, @loading_delay_ms) + assign(socket, show_loading_timer: show_loading_timer) + end +end diff --git a/lib/livebook_web/live/session_live/cell_component.ex b/lib/livebook_web/live/session_live/cell_component.ex index fe8ef14ea..0ad184175 100644 --- a/lib/livebook_web/live/session_live/cell_component.ex +++ b/lib/livebook_web/live/session_live/cell_component.ex @@ -66,7 +66,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
- <%= render_markdown_content_placeholder(empty: @cell_view.empty?) %> + <%= render_content_placeholder("bg-gray-200", @cell_view.empty?) %>
@@ -217,7 +217,9 @@ defmodule LivebookWeb.SessionLive.CellComponent do id="editor-container-<%= @cell_view.id %>" data-element="editor-container" phx-update="ignore"> - <%= render_editor_content_placeholder(empty: @cell_view.empty?) %> +
+ <%= render_content_placeholder("bg-gray-500", @cell_view.empty?) %> +
<%= if @cell_view.type == :elixir do %> @@ -243,7 +245,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do # There may be a tiny delay before the markdown is rendered # or editors are mounted, so show neat placeholders immediately. - defp render_markdown_content_placeholder(empty: true) do + defp render_content_placeholder(_bg_class, true = _empty) do assigns = %{} ~L""" @@ -251,37 +253,15 @@ defmodule LivebookWeb.SessionLive.CellComponent do """ end - defp render_markdown_content_placeholder(empty: false) do - assigns = %{} + defp render_content_placeholder(bg_class, false = _empty) do + assigns = %{bg_class: bg_class} ~L"""
-
-
-
-
-
- """ - end - - defp render_editor_content_placeholder(empty: true) do - assigns = %{} - - ~L""" -
- """ - end - - defp render_editor_content_placeholder(empty: false) do - assigns = %{} - - ~L""" -
-
-
-
-
+
+
+
""" @@ -289,7 +269,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do defp render_outputs(assigns, socket) do ~L""" -
+
<%= for {output, index} <- @cell_view.outputs |> Enum.reverse() |> Enum.with_index(), output != :ignored do %>
<%= render_output(socket, output, "cell-#{@cell_view.id}-output#{index}") %> @@ -322,6 +302,13 @@ defmodule LivebookWeb.SessionLive.CellComponent do ) end + defp render_output(socket, {:table_dynamic, pid}, id) do + live_render(socket, LivebookWeb.Output.TableDynamicLive, + id: id, + session: %{"id" => id, "pid" => pid} + ) + end + defp render_output(_socket, {:error, formatted}, _id) do render_error_message_output(formatted) end @@ -348,7 +335,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
<%= line %>
<% end %>
-