Added designs import resource

Necessary to import an exported (zip file) design. The full implementation is included as a single commit to capture the details of how this works. Here's the breakdown:

* *Import Action*: Creates a new screen template based on the model ID and associated zip file content that is uploaded.
* *Import Partial*: The `_import` partial provides the form within a popover via the index page to collect details from the user. This is also why the `index` template has been updated to link to the popover and also why the index view supplies the models.

In the future, reduce the responsibility of loading the models on the index view and use a import new action instead.

Milestone: minor
This commit is contained in:
Brooke Kuhlmann
2026-08-12 11:42:35 -06:00
parent de3dc101d7
commit 6e7d74b6d9
7 changed files with 187 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
# frozen_string_literal: true
require "dry/monads"
module Terminus
module Actions
module Designs
module Import
# The create action.
class Create < Action
include Deps["aspects.designs.importer", "aspects.screens.upserter"]
include Dry::Monads[:result]
contract Contracts::Designs::Import
def handle request, response
parameters = request.params
halt :unprocessable_content unless parameters.valid?
process parameters, response
end
private
def process parameters, response
flash = response.flash
case upsert parameters
in Success then flash[:notice] = "Design imported!"
in Failure(message) then flash[:alert] = message
end
response.redirect_to routes.path(:designs)
end
def upsert parameters
model_id, design = parameters.to_h.values_at :model_id, :design
importer.call(design.dig(:attachment, :tempfile))
.bind do |screen_template|
upserter.call(model_id:, **screen_template.screen_attributes)
end
end
end
end
end
end
end
+38
View File
@@ -0,0 +1,38 @@
<%= scope(:popover_default_content, name: :import, label: "Import").render do %>
<%= form_for :design,
routes.path(:design_import),
enctype: "multipart/form-data",
class: "bit-form" do |form| %>
<p>Please attach a zip file to import a previously exported design into your collection.</p>
<%= tag.select id: :model_id, name: :model_id, class: :value do %>
<%= tag.option "Select model...", value: nil %>
<% models.each do |model| %>
<%= tag.option model.label, value: model.id %>
<% end %>
<% end %>
<%= form.file_field :attachment,
id: :design_attachment,
value: nil,
class: :value,
accept: "application/zip" %>
<div class="form-actions">
<%= form.submit "Submit",
class: "bit-button-accept",
**HTMX[
post: routes.path(:design_import),
encoding: "multipart/form-data",
trigger: :click,
target: ".site-body",
select: ".site-body",
disabled_elt: "this"
] %>
<%= link_to "Cancel", routes.path(:designs), class: "bit-button-decline" %>
</div>
<% end %>
<% end %>
+13
View File
@@ -15,10 +15,22 @@
<div class="bit-actions">
<%= render "shared/actions/search", path: routes.path(:designs), query: %>
<%= scope(
:popover_trigger_button,
icon: :upload,
tip: :import,
target: :import,
width: 25,
height: 25
).render %>
<%= render "shared/actions/new", path: routes.path(:design_new) %>
</div>
</header>
<%= render :import, models: %>
<%= scope(:popover_default_content, name: :overview, label: "Overview").render do %>
<p>Use to manage templates for building screens. Each template can have multiple screens associated with it.</p>
@@ -39,6 +51,7 @@
<%= scope(:tooltip_action_content, label: "Info").render %>
<%= scope(:tooltip_action_content, label: "New").render %>
<%= scope(:tooltip_action_content, label: "Import").render %>
<%= scope(:tooltip_action_content, label: "Search").render %>
<%= scope(:tooltip_action_content, label: "Clear").render %>
<%= scope(:tooltip_action_content, label: "View").render %>
+3
View File
@@ -5,6 +5,9 @@ module Terminus
module Designs
# The index view.
class Index < View
include Deps[model_repository: "repositories.model"]
expose(:models) { model_repository.all }
expose :templates
expose :query
end
+1
View File
@@ -85,6 +85,7 @@ module Terminus
delete "/designs/:id", to: "designs.delete", as: :design
get "/designs/:design_id/export", to: "designs.export.show", as: :design_export
post "/designs/import", to: "designs.import.create", as: :design_import
get "/extensions", to: "extensions.index", as: :extensions
get "/extensions/new", to: "extensions.new", as: :extension_new
@@ -0,0 +1,60 @@
# frozen_string_literal: true
require "hanami_helper"
RSpec.describe Terminus::Actions::Designs::Import::Create, :db do
subject(:action) { described_class.new }
describe "#call" do
let(:exporter) { Terminus::Aspects::Designs::Exporter.new }
let(:model) { Factory[:model] }
let(:screen_template) { Factory.structs[:screen_template] }
it "renders errors when invalid" do
allow(screen_template).to receive(:export_attributes).and_return({})
response = action.call Rack::MockRequest.env_for(
"",
"router.params" => {
model_id: model.id,
design: {
attachment: {
name: "test",
type: "application/zip",
head: "test",
filename: "test.zip",
tempfile: exporter.call(screen_template).value!
}
}
}
)
expect(response.flash.inspect).to include("label is missing")
end
it "flashs success when valid" do
response = action.call Rack::MockRequest.env_for(
"",
"router.params" => {
model_id: model.id,
design: {
attachment: {
name: "test",
type: "application/zip",
head: "test",
filename: "test.zip",
tempfile: exporter.call(screen_template).value!
}
}
}
)
expect(response.flash.inspect).to include("Design imported!")
end
it "answers unprocessable content when parameters are missing" do
response = Rack::MockRequest.new(action).post("")
expect(response.status).to eq(422)
end
end
end
+23
View File
@@ -3,6 +3,8 @@
require "hanami_helper"
RSpec.describe "Designs", :db do
using Refinements::Pathname
let(:model) { Factory[:model] }
let(:template) { Factory[:screen_template] }
@@ -30,6 +32,27 @@ RSpec.describe "Designs", :db do
expect(page).to have_text("Edit Design")
end
it "imports", :aggregate_failures, :js do
model
exporter = Terminus::Aspects::Designs::Exporter.new
importer = Terminus::Aspects::Designs::Importer.new
screen_template = Factory.structs[:screen_template, label: "Design Import Test"]
path = exporter.call(screen_template).bind { |io| temp_dir.join("test.zip").write io.read }
path.open { importer.call it }
visit routes.path(:designs)
click_button "Upload"
within ".bit-popover-content", text: "Import" do
select model.label, from: "model_id"
attach_file "design_attachment", path
click_button "Submit"
end
expect(page).to have_text("Design Import Test")
end
it "exports" do
visit routes.path(:designs)
click_link "Download"