feat: adding "+" buttons to sidebar titles

This commit is contained in:
2026-04-26 22:05:55 +02:00
parent 334ffe6f6a
commit 0d7a68bc0f
11 changed files with 342 additions and 27 deletions

View File

@@ -5,7 +5,7 @@ defmodule BDS.Desktop.ShellLive do
import Phoenix.HTML
alias BDS.Desktop.{FolderPicker, Overlay, ShellCommands, ShellData}
alias BDS.Desktop.{FilePicker, FolderPicker, Overlay, ShellCommands, ShellData}
alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, MediaEditor, MiscEditor, SettingsEditor, TagsEditor}
alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents
alias BDS.Desktop.ShellLive.PostEditor
@@ -13,11 +13,13 @@ defmodule BDS.Desktop.ShellLive do
alias BDS.Desktop.ShellLive.SidebarState, as: ShellSidebarState
alias BDS.Desktop.MenuBar, as: DesktopMenuBar
alias BDS.Git
alias BDS.ImportDefinitions
alias BDS.Media.Media
alias BDS.PostLinks
alias BDS.Posts.Post
alias BDS.Projects
alias BDS.Repo
alias BDS.Scripts
alias BDS.Templates
alias BDS.UI.{Commands, MenuBar, Registry, Session, Workbench}
@@ -282,6 +284,10 @@ defmodule BDS.Desktop.ShellLive do
|> reload_shell(socket.assigns.workbench)}
end
def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do
{:noreply, create_sidebar_item(socket, kind)}
end
def handle_event("shortcut", params, socket) do
if ignore_shortcut?(params) do
{:noreply, socket}
@@ -1345,6 +1351,102 @@ defmodule BDS.Desktop.ShellLive do
Map.get(params, :tag) in ["INPUT", "TEXTAREA", "SELECT"]
end
defp create_sidebar_item(socket, kind) do
case socket.assigns.projects.active_project_id do
project_id when is_binary(project_id) -> create_sidebar_item(socket, project_id, kind)
_other -> reload_shell(socket, socket.assigns.workbench)
end
end
defp create_sidebar_item(socket, project_id, "post") do
case BDS.Posts.create_post(%{project_id: project_id, title: "", content: "", tags: [], categories: []}) do
{:ok, _post} -> reload_shell(socket, socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output_entry(translated("sidebar.newPost"), inspect(reason), nil, "error")
|> reload_shell(socket.assigns.workbench)
end
end
defp create_sidebar_item(socket, project_id, "media") do
case FilePicker.choose_file(translated("sidebar.importMedia")) do
{:ok, source_path} ->
case BDS.Media.import_media(%{project_id: project_id, source_path: source_path}) do
{:ok, _media} -> reload_shell(socket, socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output_entry(translated("sidebar.importMedia"), inspect(reason), nil, "error")
|> reload_shell(socket.assigns.workbench)
end
:cancel ->
reload_shell(socket, socket.assigns.workbench)
{:error, %{message: message}} ->
socket
|> append_output_entry(translated("sidebar.importMedia"), message, nil, "error")
|> reload_shell(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output_entry(translated("sidebar.importMedia"), inspect(reason), nil, "error")
|> reload_shell(socket.assigns.workbench)
end
end
defp create_sidebar_item(socket, project_id, "script") do
case Scripts.create_script(%{
project_id: project_id,
title: translated("sidebar.scripts.newScript"),
kind: :utility,
content: "print(\"new script\")",
entrypoint: "main",
enabled: true
}) do
{:ok, script} ->
open_sidebar_item(socket, %{"route" => "scripts", "id" => script.id, "title" => script.title, "subtitle" => "Automation helpers"}, :pin)
{:error, reason} ->
socket
|> append_output_entry(translated("sidebar.scripts.newScript"), inspect(reason), nil, "error")
|> reload_shell(socket.assigns.workbench)
end
end
defp create_sidebar_item(socket, project_id, "template") do
case Templates.create_template(%{
project_id: project_id,
title: translated("sidebar.templates.newTemplate"),
kind: :post,
content: "",
enabled: true
}) do
{:ok, template} ->
open_sidebar_item(socket, %{"route" => "templates", "id" => template.id, "title" => template.title, "subtitle" => "Site rendering"}, :pin)
{:error, reason} ->
socket
|> append_output_entry(translated("sidebar.templates.newTemplate"), inspect(reason), nil, "error")
|> reload_shell(socket.assigns.workbench)
end
end
defp create_sidebar_item(socket, project_id, "import") do
case ImportDefinitions.create_definition(%{project_id: project_id, name: translated("sidebar.import.newDefinition")}) do
{:ok, definition} ->
open_sidebar_item(socket, %{"route" => "import", "id" => definition.id, "title" => definition.name, "subtitle" => "Import definitions"}, :pin)
{:error, reason} ->
socket
|> append_output_entry(translated("sidebar.import.newDefinition"), inspect(reason), nil, "error")
|> reload_shell(socket.assigns.workbench)
end
end
defp create_sidebar_item(socket, _project_id, _kind), do: reload_shell(socket, socket.assigns.workbench)
defp open_sidebar_item(socket, params, intent) do
route_atom = sidebar_route_atom(Map.fetch!(params, "route"))
tab_id = tab_id_for_route(route_atom, Map.fetch!(params, "id"))
@@ -1364,6 +1466,13 @@ defmodule BDS.Desktop.ShellLive do
|> reload_shell(workbench)
end
defp sidebar_create_action(:posts), do: %{kind: "post", label: "sidebar.newPost"}
defp sidebar_create_action(:media), do: %{kind: "media", label: "sidebar.importMedia"}
defp sidebar_create_action(:scripts), do: %{kind: "script", label: "sidebar.scripts.newScript"}
defp sidebar_create_action(:templates), do: %{kind: "template", label: "sidebar.templates.newTemplate"}
defp sidebar_create_action(:import), do: %{kind: "import", label: "sidebar.import.newDefinition"}
defp sidebar_create_action(_view), do: nil
defp set_page_language(socket, language) do
codes = Enum.map(socket.assigns[:supported_ui_languages] || ShellData.supported_ui_languages(), & &1.code)

View File

@@ -164,10 +164,12 @@
<div class="sidebar" data-region="sidebar">
<div id="sidebar-content" class="sidebar-content sidebar-body" phx-hook="SidebarInteractions">
<div class="sidebar-section">
<% create_action = sidebar_create_action(@workbench.active_view) %>
<div class="sidebar-section-header">
<span><%= String.upcase(sidebar_header_label(@sidebar_header)) %></span>
<%= if ShellSidebarComponents.filters_enabled?(@sidebar_data) do %>
<%= if create_action || ShellSidebarComponents.filters_enabled?(@sidebar_data) do %>
<div class="sidebar-actions">
<%= if ShellSidebarComponents.filters_enabled?(@sidebar_data) do %>
<button
class={[
"sidebar-action",
@@ -183,6 +185,23 @@
<path d="M6 12v-1h4v1H6zM4 8v-1h8v1H4zm-2-4v-1h12v1H2z"/>
</svg>
</button>
<% end %>
<%= if create_action do %>
<button
class="sidebar-action"
data-testid="sidebar-create-action"
data-sidebar-action={create_action.kind}
type="button"
phx-click="create_sidebar_item"
phx-value-kind={create_action.kind}
aria-label={translated(create_action.label)}
title={translated(create_action.label)}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
</svg>
</button>
<% end %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,37 @@
defmodule BDS.ImportDefinitions do
@moduledoc false
import Ecto.Query
alias BDS.ImportDefinitions.ImportDefinition
alias BDS.Persistence
alias BDS.Repo
def create_definition(attrs) do
now = Persistence.now_ms()
%ImportDefinition{}
|> ImportDefinition.changeset(%{
id: Ecto.UUID.generate(),
project_id: attr(attrs, :project_id),
name: attr(attrs, :name) || "",
wxr_file_path: attr(attrs, :wxr_file_path),
uploads_folder_path: attr(attrs, :uploads_folder_path),
last_analysis_result: attr(attrs, :last_analysis_result),
created_at: now,
updated_at: now
})
|> Repo.insert()
end
def list_definitions(project_id) do
Repo.all(
from definition in ImportDefinition,
where: definition.project_id == ^project_id,
order_by: [desc: definition.updated_at, desc: definition.created_at],
select: %{id: definition.id, title: definition.name, updated_at: definition.updated_at}
)
end
defp attr(attrs, key), do: Map.get(attrs, key) || Map.get(attrs, Atom.to_string(key))
end

View File

@@ -0,0 +1,25 @@
defmodule BDS.ImportDefinitions.ImportDefinition do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :string, autogenerate: false}
schema "import_definitions" do
field :project_id, :string
field :name, :string
field :wxr_file_path, :string
field :uploads_folder_path, :string
field :last_analysis_result, :string
field :created_at, :integer
field :updated_at, :integer
end
def changeset(definition, attrs) do
definition
|> cast(attrs, [:id, :project_id, :name, :wxr_file_path, :uploads_folder_path, :last_analysis_result, :created_at, :updated_at])
|> validate_required([:id, :project_id, :name, :created_at, :updated_at])
end
end

View File

@@ -4,6 +4,7 @@ defmodule BDS.UI.Sidebar do
import Ecto.Query
alias BDS.AI.ChatConversation
alias BDS.ImportDefinitions
alias BDS.Media.Media
alias BDS.Posts.Post
alias BDS.Posts.Translation
@@ -26,7 +27,7 @@ defmodule BDS.UI.Sidebar do
"templates" => view(project_id, "templates"),
"tags" => view(project_id, "tags"),
"chat" => view(project_id, "chat"),
"import" => entity_list_view("Import", "Import definitions", "import", []),
"import" => entity_list_view("Import", "Import definitions", "import", list_import_definitions(project_id)),
"git" => git_view(),
"settings" => settings_nav_view()
}
@@ -47,7 +48,7 @@ defmodule BDS.UI.Sidebar do
"templates" -> entity_list_view("Templates", "Site rendering", "templates", list_templates(project_id))
"tags" -> tags_nav_view(list_tags(project_id))
"chat" -> entity_list_view("Chat", "AI conversations", "chat", list_conversations())
"import" -> entity_list_view("Import", "Import definitions", "import", [])
"import" -> entity_list_view("Import", "Import definitions", "import", list_import_definitions(project_id))
"git" -> git_view()
"settings" -> settings_nav_view()
_other -> empty_view(normalized_view)
@@ -359,6 +360,10 @@ defmodule BDS.UI.Sidebar do
)
end
defp list_import_definitions(project_id) do
ImportDefinitions.list_definitions(project_id)
end
defp list_tags(project_id) do
Repo.all(
from tag in Tag,

View File

@@ -57,6 +57,11 @@
"sidebar.searchPagesPlaceholder": "Seiten durchsuchen...",
"sidebar.searchMediaPlaceholder": "Medien durchsuchen...",
"sidebar.toggleFilters": "Filter umschalten",
"sidebar.newPost": "Neuer Beitrag",
"sidebar.importMedia": "Medien importieren",
"sidebar.import.newDefinition": "Neue Importdefinition",
"sidebar.scripts.newScript": "Neues Skript",
"sidebar.templates.newTemplate": "Neue Vorlage",
"sidebar.results": "%{count} Ergebnisse",
"sidebar.resultsFor": "%{count} Ergebnisse für \"%{query}\"",
"sidebar.clearFilters": "Filter löschen",

View File

@@ -57,6 +57,11 @@
"sidebar.searchPagesPlaceholder": "Search pages...",
"sidebar.searchMediaPlaceholder": "Search media...",
"sidebar.toggleFilters": "Toggle Filters",
"sidebar.newPost": "New Post",
"sidebar.importMedia": "Import media",
"sidebar.import.newDefinition": "New Import Definition",
"sidebar.scripts.newScript": "New Script",
"sidebar.templates.newTemplate": "New Template",
"sidebar.results": "%{count} results",
"sidebar.resultsFor": "%{count} results for \"%{query}\"",
"sidebar.clearFilters": "Clear filters",

View File

@@ -57,6 +57,11 @@
"sidebar.searchPagesPlaceholder": "Buscar páginas...",
"sidebar.searchMediaPlaceholder": "Buscar medios...",
"sidebar.toggleFilters": "Alternar filtros",
"sidebar.newPost": "Nueva entrada",
"sidebar.importMedia": "Importar medios",
"sidebar.import.newDefinition": "Nueva definición",
"sidebar.scripts.newScript": "Nuevo script",
"sidebar.templates.newTemplate": "Nueva plantilla",
"sidebar.results": "%{count} resultados",
"sidebar.resultsFor": "%{count} resultados para \"%{query}\"",
"sidebar.clearFilters": "Limpiar filtros",

View File

@@ -57,6 +57,11 @@
"sidebar.searchPagesPlaceholder": "Rechercher des pages...",
"sidebar.searchMediaPlaceholder": "Rechercher des médias...",
"sidebar.toggleFilters": "Afficher/masquer les filtres",
"sidebar.newPost": "Nouvel article",
"sidebar.importMedia": "Importer des médias",
"sidebar.import.newDefinition": "Nouvelle définition",
"sidebar.scripts.newScript": "Nouveau script",
"sidebar.templates.newTemplate": "Nouveau modèle",
"sidebar.results": "%{count} résultats",
"sidebar.resultsFor": "%{count} résultats pour \"%{query}\"",
"sidebar.clearFilters": "Effacer les filtres",

View File

@@ -57,6 +57,11 @@
"sidebar.searchPagesPlaceholder": "Cerca pagine...",
"sidebar.searchMediaPlaceholder": "Cerca media...",
"sidebar.toggleFilters": "Mostra/nascondi filtri",
"sidebar.newPost": "Nuovo post",
"sidebar.importMedia": "Importa media",
"sidebar.import.newDefinition": "Nuova definizione",
"sidebar.scripts.newScript": "Nuovo script",
"sidebar.templates.newTemplate": "Nuovo modello",
"sidebar.results": "%{count} risultati",
"sidebar.resultsFor": "%{count} risultati per \"%{query}\"",
"sidebar.clearFilters": "Cancella filtri",

View File

@@ -15,6 +15,7 @@ defmodule BDS.Desktop.ShellLiveTest do
alias BDS.Scripts
alias BDS.Templates
alias BDS.Tags
alias BDS.ImportDefinitions
alias BDS.UI.{Session, Workbench}
@endpoint BDS.Desktop.Endpoint
@@ -51,6 +52,106 @@ defmodule BDS.Desktop.ShellLiveTest do
%{project: project, temp_dir: temp_dir}
end
test "sidebar headers expose old-app create actions for posts, media, scripts, templates, and imports" do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
assert html =~ ~s(data-testid="sidebar-create-action")
assert html =~ ~s(data-sidebar-action="post")
assert html =~ ~s(data-testid="sidebar-filter-toggle")
html = render_click(view, "select_view", %{"view" => "media"})
assert html =~ ~s(data-sidebar-action="media")
assert html =~ ~s(data-testid="sidebar-filter-toggle")
_html =
view
|> element("[data-testid='activity-button'][data-view='scripts']")
|> render_click()
assert html =~ ~s(data-sidebar-action="script")
_html =
view
|> element("[data-testid='activity-button'][data-view='templates']")
|> render_click()
assert html =~ ~s(data-sidebar-action="template")
_html =
view
|> element("[data-testid='activity-button'][data-view='import']")
|> render_click()
assert html =~ ~s(data-sidebar-action="import")
end
test "sidebar create actions follow the old-app post, script, template, and import flows", %{project: project} do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
post_count_before = Repo.aggregate(Post, :count, :id)
script_count_before = Repo.aggregate(BDS.Scripts.Script, :count, :id)
template_count_before = Repo.aggregate(BDS.Templates.Template, :count, :id)
import_count_before = Repo.aggregate(ImportDefinitions.ImportDefinition, :count, :id)
html =
view
|> element("[data-testid='sidebar-create-action'][data-sidebar-action='post']")
|> render_click()
assert Repo.aggregate(Post, :count, :id) == post_count_before + 1
created_post = Repo.one!(Post)
assert created_post.project_id == project.id
assert created_post.title == ""
assert created_post.content == ""
refute html =~ ~s(data-tab-type="post")
html = render_click(view, "select_view", %{"view" => "scripts"})
html =
view
|> element("[data-testid='sidebar-create-action'][data-sidebar-action='script']")
|> render_click()
assert Repo.aggregate(BDS.Scripts.Script, :count, :id) == script_count_before + 1
created_script = Repo.one!(BDS.Scripts.Script)
assert created_script.project_id == project.id
assert created_script.title == "New Script"
assert created_script.entrypoint == "main"
assert created_script.content == "print(\"new script\")"
assert html =~ ~s(data-tab-type="scripts")
assert html =~ ~s(data-tab-id="#{created_script.id}")
html = render_click(view, "select_view", %{"view" => "templates"})
html =
view
|> element("[data-testid='sidebar-create-action'][data-sidebar-action='template']")
|> render_click()
assert Repo.aggregate(BDS.Templates.Template, :count, :id) == template_count_before + 1
created_template = Repo.get_by!(BDS.Templates.Template, title: "New Template")
assert created_template.project_id == project.id
assert created_template.title == "New Template"
assert created_template.content == ""
assert html =~ ~s(data-tab-type="templates")
assert html =~ ~s(data-tab-id="#{created_template.id}")
html = render_click(view, "select_view", %{"view" => "import"})
render_click(view, "select_view", %{"view" => "scripts"})
assert Repo.aggregate(ImportDefinitions.ImportDefinition, :count, :id) == import_count_before + 1
created_definition = Repo.one!(ImportDefinitions.ImportDefinition)
assert created_definition.project_id == project.id
assert created_definition.name == "New Import Definition"
assert html =~ ~s(data-tab-type="import")
assert html =~ ~s(data-tab-id="#{created_definition.id}")
end
test "shell live owns pane visibility and activity selection on the server" do
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
@@ -63,17 +164,11 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-view="media")
assert html =~ ~s(aria-label="Posts")
html =
view
|> element("[data-testid='toggle-sidebar']")
|> render_click()
render_click(view, "select_view", %{"view" => "templates"})
assert html =~ ~s(class="sidebar-shell is-hidden")
html =
view
|> element("[data-testid='toggle-panel']")
|> render_click()
render_click(view, "select_view", %{"view" => "import"})
assert html =~ ~s(data-region="panel")
refute html =~ ~s(class="panel-shell is-hidden")