feat: adds pdf export with imprintor
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
parent
496e2e438f
commit
f6b35f03a5
16 changed files with 1962 additions and 70 deletions
356
test/mv/membership/member_export_build_test.exs
Normal file
356
test/mv/membership/member_export_build_test.exs
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
defmodule Mv.Membership.MemberExport.BuildTest do
|
||||
@moduledoc """
|
||||
Tests for MemberExport.Build module.
|
||||
|
||||
Tests verify that the module correctly:
|
||||
- Loads and filters members based on query/selected_ids
|
||||
- Builds column specifications (without labels)
|
||||
- Generates row data as cell strings
|
||||
- Handles member fields, custom fields, and computed fields
|
||||
- Applies sorting and filtering consistently
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.Fixtures
|
||||
alias Mv.Membership.{CustomField, Member, MemberExport.Build}
|
||||
alias Mv.Constants
|
||||
|
||||
@custom_field_prefix Constants.custom_field_prefix()
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Create test members
|
||||
member1 =
|
||||
Fixtures.member_fixture(%{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|
||||
member2 =
|
||||
Fixtures.member_fixture(%{
|
||||
first_name: "Bob",
|
||||
last_name: "Brown",
|
||||
email: "bob@example.com"
|
||||
})
|
||||
|
||||
%{actor: system_actor, member1: member1, member2: member2}
|
||||
end
|
||||
|
||||
describe "build/3 - standard member fields" do
|
||||
test "returns columns and rows for standard member fields", %{
|
||||
actor: actor,
|
||||
member1: m1,
|
||||
member2: m2
|
||||
} do
|
||||
parsed = %{
|
||||
selected_ids: [m1.id, m2.id],
|
||||
member_fields: ["first_name", "last_name", "email"],
|
||||
selectable_member_fields: ["first_name", "last_name", "email"],
|
||||
computed_fields: [],
|
||||
custom_field_ids: [],
|
||||
query: nil,
|
||||
sort_field: nil,
|
||||
sort_order: nil,
|
||||
show_current_cycle: false
|
||||
}
|
||||
|
||||
result = Build.build(actor, parsed, fn _key -> "Label" end)
|
||||
|
||||
assert {:ok, data} = result
|
||||
assert %{columns: columns, rows: rows, meta: meta} = data
|
||||
|
||||
# Check columns structure
|
||||
assert length(columns) == 3
|
||||
first_name_col = Enum.find(columns, &(&1.key == "first_name" && &1.kind == :member_field))
|
||||
assert first_name_col
|
||||
assert first_name_col.label == "Label"
|
||||
assert Enum.find(columns, &(&1.key == "last_name" && &1.kind == :member_field))
|
||||
assert Enum.find(columns, &(&1.key == "email" && &1.kind == :member_field))
|
||||
|
||||
# Check rows - should have 2 members
|
||||
assert length(rows) == 2
|
||||
|
||||
# Check first row (member1)
|
||||
row1 = Enum.at(rows, 0)
|
||||
assert length(row1) == 3
|
||||
assert "Alice" in row1
|
||||
assert "Anderson" in row1
|
||||
assert "alice@example.com" in row1
|
||||
|
||||
# Check meta
|
||||
assert %{generated_at: _timestamp, member_count: 2} = meta
|
||||
assert is_binary(meta.generated_at)
|
||||
end
|
||||
|
||||
test "filters members by selected_ids", %{actor: actor, member1: m1, member2: _m2} do
|
||||
parsed = %{
|
||||
selected_ids: [m1.id],
|
||||
member_fields: ["first_name"],
|
||||
selectable_member_fields: ["first_name"],
|
||||
computed_fields: [],
|
||||
custom_field_ids: [],
|
||||
query: nil,
|
||||
sort_field: nil,
|
||||
sort_order: nil,
|
||||
show_current_cycle: false
|
||||
}
|
||||
|
||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
||||
|
||||
assert length(data.rows) == 1
|
||||
row = hd(data.rows)
|
||||
assert "Alice" in row
|
||||
assert data.meta.member_count == 1
|
||||
end
|
||||
|
||||
test "applies search query when selected_ids is empty", %{
|
||||
actor: actor,
|
||||
member1: m1,
|
||||
member2: _m2
|
||||
} do
|
||||
parsed = %{
|
||||
selected_ids: [],
|
||||
member_fields: ["first_name", "last_name"],
|
||||
selectable_member_fields: ["first_name", "last_name"],
|
||||
computed_fields: [],
|
||||
custom_field_ids: [],
|
||||
query: "Alice",
|
||||
sort_field: nil,
|
||||
sort_order: nil,
|
||||
show_current_cycle: false
|
||||
}
|
||||
|
||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
||||
|
||||
assert length(data.rows) == 1
|
||||
row = hd(data.rows)
|
||||
assert "Alice" in row
|
||||
end
|
||||
end
|
||||
|
||||
describe "build/3 - custom fields" do
|
||||
test "includes custom field columns and values", %{
|
||||
actor: actor,
|
||||
member1: m1
|
||||
} do
|
||||
# Create custom field
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Membership Number",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Create custom field value for member
|
||||
{:ok, _cfv} =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: m1.id,
|
||||
custom_field_id: custom_field.id,
|
||||
value: "M12345"
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
parsed = %{
|
||||
selected_ids: [m1.id],
|
||||
member_fields: ["first_name"],
|
||||
selectable_member_fields: ["first_name"],
|
||||
computed_fields: [],
|
||||
custom_field_ids: [custom_field.id],
|
||||
query: nil,
|
||||
sort_field: nil,
|
||||
sort_order: nil,
|
||||
show_current_cycle: false
|
||||
}
|
||||
|
||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
||||
|
||||
# Should have 2 columns: first_name + custom field
|
||||
assert length(data.columns) == 2
|
||||
|
||||
custom_col =
|
||||
Enum.find(
|
||||
data.columns,
|
||||
&(&1.kind == :custom_field && &1.key == to_string(custom_field.id))
|
||||
)
|
||||
|
||||
assert custom_col
|
||||
assert custom_col.custom_field.id == custom_field.id
|
||||
assert custom_col.label == "Label"
|
||||
|
||||
# Check row has custom field value
|
||||
row = hd(data.rows)
|
||||
assert length(row) == 2
|
||||
assert "M12345" in row
|
||||
end
|
||||
|
||||
test "handles members without custom field values", %{
|
||||
actor: actor,
|
||||
member1: m1
|
||||
} do
|
||||
# Create custom field but no value for member
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Optional Field",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
parsed = %{
|
||||
selected_ids: [m1.id],
|
||||
member_fields: ["first_name"],
|
||||
selectable_member_fields: ["first_name"],
|
||||
computed_fields: [],
|
||||
custom_field_ids: [custom_field.id],
|
||||
query: nil,
|
||||
sort_field: nil,
|
||||
sort_order: nil,
|
||||
show_current_cycle: false
|
||||
}
|
||||
|
||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
||||
|
||||
row = hd(data.rows)
|
||||
# Custom field value should be empty string
|
||||
assert "" in row
|
||||
end
|
||||
end
|
||||
|
||||
describe "build/3 - computed fields" do
|
||||
test "includes computed field columns and values", %{
|
||||
actor: actor,
|
||||
member1: m1
|
||||
} do
|
||||
parsed = %{
|
||||
selected_ids: [m1.id],
|
||||
member_fields: ["first_name", "membership_fee_status"],
|
||||
selectable_member_fields: ["first_name"],
|
||||
computed_fields: ["membership_fee_status"],
|
||||
custom_field_ids: [],
|
||||
query: nil,
|
||||
sort_field: nil,
|
||||
sort_order: nil,
|
||||
show_current_cycle: false
|
||||
}
|
||||
|
||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
||||
|
||||
# Should have 2 columns: first_name + computed field
|
||||
assert length(data.columns) == 2
|
||||
|
||||
computed_col =
|
||||
Enum.find(data.columns, &(&1.kind == :computed && &1.key == :membership_fee_status))
|
||||
|
||||
assert computed_col
|
||||
assert computed_col.label == "Label"
|
||||
|
||||
# Check row has computed field value (may be empty if no cycles)
|
||||
row = hd(data.rows)
|
||||
assert length(row) == 2
|
||||
# membership_fee_status should be present (even if empty)
|
||||
end
|
||||
end
|
||||
|
||||
describe "build/3 - sorting" do
|
||||
test "sorts by member field", %{actor: actor, member1: m1, member2: m2} do
|
||||
parsed = %{
|
||||
selected_ids: [m1.id, m2.id],
|
||||
member_fields: ["first_name"],
|
||||
selectable_member_fields: ["first_name"],
|
||||
computed_fields: [],
|
||||
custom_field_ids: [],
|
||||
query: nil,
|
||||
sort_field: "first_name",
|
||||
sort_order: "asc",
|
||||
show_current_cycle: false
|
||||
}
|
||||
|
||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
||||
|
||||
# Should be sorted: Alice, Bob
|
||||
[row1, row2] = data.rows
|
||||
assert "Alice" in row1
|
||||
assert "Bob" in row2
|
||||
end
|
||||
|
||||
test "sorts by custom field", %{actor: actor, member1: m1, member2: m2} do
|
||||
# Create custom field
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Sort Field",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Add values: m1="Zebra", m2="Alpha"
|
||||
{:ok, _cfv1} =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: m1.id,
|
||||
custom_field_id: custom_field.id,
|
||||
value: "Zebra"
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
{:ok, _cfv2} =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: m2.id,
|
||||
custom_field_id: custom_field.id,
|
||||
value: "Alpha"
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
sort_field = "#{@custom_field_prefix}#{custom_field.id}"
|
||||
|
||||
parsed = %{
|
||||
selected_ids: [m1.id, m2.id],
|
||||
member_fields: ["first_name"],
|
||||
selectable_member_fields: ["first_name"],
|
||||
computed_fields: [],
|
||||
custom_field_ids: [custom_field.id],
|
||||
query: nil,
|
||||
sort_field: sort_field,
|
||||
sort_order: "asc",
|
||||
show_current_cycle: false
|
||||
}
|
||||
|
||||
{:ok, data} = Build.build(actor, parsed, fn _key -> "Label" end)
|
||||
|
||||
# Should be sorted by custom field: Alpha (Bob), Zebra (Alice)
|
||||
[row1, row2] = data.rows
|
||||
# Alpha
|
||||
assert "Bob" in row1
|
||||
# Zebra
|
||||
assert "Alice" in row2
|
||||
end
|
||||
end
|
||||
|
||||
describe "build/3 - error handling" do
|
||||
test "returns error when actor lacks permission", %{member1: m1} do
|
||||
# Create a user with limited permissions
|
||||
user = Fixtures.password_user_with_role_fixture(%{permission_set_name: "own_data"})
|
||||
|
||||
parsed = %{
|
||||
selected_ids: [m1.id],
|
||||
member_fields: ["first_name"],
|
||||
selectable_member_fields: ["first_name"],
|
||||
computed_fields: [],
|
||||
custom_field_ids: [],
|
||||
query: nil,
|
||||
sort_field: nil,
|
||||
sort_order: nil,
|
||||
show_current_cycle: false
|
||||
}
|
||||
|
||||
result = Build.build(user, parsed, fn _key -> "Label" end)
|
||||
|
||||
assert {:error, :forbidden} = result
|
||||
end
|
||||
end
|
||||
end
|
||||
265
test/mv/membership/members_pdf_test.exs
Normal file
265
test/mv/membership/members_pdf_test.exs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
defmodule Mv.Membership.MembersPDFTest do
|
||||
@moduledoc """
|
||||
Tests for MembersPDF module.
|
||||
|
||||
Tests verify that the module correctly:
|
||||
- Loads the Typst template
|
||||
- Converts export data to template format
|
||||
- Generates valid PDF binary (starts with "%PDF")
|
||||
- Handles errors gracefully
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Mv.Config
|
||||
alias Mv.Membership.MembersPDF
|
||||
|
||||
describe "render/1" do
|
||||
test "rejects export when row count exceeds limit" do
|
||||
max_rows = Config.pdf_export_row_limit()
|
||||
rows_over_limit = max_rows + 1
|
||||
|
||||
export_data = %{
|
||||
columns: [
|
||||
%{key: "first_name", kind: :member_field, label: "Vorname"}
|
||||
],
|
||||
rows: Enum.map(1..rows_over_limit, fn i -> ["Member #{i}"] end),
|
||||
meta: %{
|
||||
generated_at: "2024-01-15T14:30:00Z",
|
||||
member_count: rows_over_limit
|
||||
}
|
||||
}
|
||||
|
||||
result = MembersPDF.render(export_data)
|
||||
|
||||
assert {:error, {:row_limit_exceeded, ^rows_over_limit, ^max_rows}} = result
|
||||
end
|
||||
|
||||
test "allows export when row count equals limit" do
|
||||
max_rows = Config.pdf_export_row_limit()
|
||||
|
||||
export_data = %{
|
||||
columns: [
|
||||
%{key: "first_name", kind: :member_field, label: "Vorname"}
|
||||
],
|
||||
rows: Enum.map(1..max_rows, fn i -> ["Member #{i}"] end),
|
||||
meta: %{
|
||||
generated_at: "2024-01-15T14:30:00Z",
|
||||
member_count: max_rows
|
||||
}
|
||||
}
|
||||
|
||||
result = MembersPDF.render(export_data)
|
||||
|
||||
assert {:ok, pdf_binary} = result
|
||||
assert String.starts_with?(pdf_binary, "%PDF")
|
||||
end
|
||||
|
||||
test "allows export when row count is below limit" do
|
||||
max_rows = Config.pdf_export_row_limit()
|
||||
rows_below_limit = max(1, max_rows - 10)
|
||||
|
||||
export_data = %{
|
||||
columns: [
|
||||
%{key: "first_name", kind: :member_field, label: "Vorname"}
|
||||
],
|
||||
rows: Enum.map(1..rows_below_limit, fn i -> ["Member #{i}"] end),
|
||||
meta: %{
|
||||
generated_at: "2024-01-15T14:30:00Z",
|
||||
member_count: rows_below_limit
|
||||
}
|
||||
}
|
||||
|
||||
result = MembersPDF.render(export_data)
|
||||
|
||||
assert {:ok, pdf_binary} = result
|
||||
assert String.starts_with?(pdf_binary, "%PDF")
|
||||
end
|
||||
|
||||
test "generates valid PDF from minimal dataset" do
|
||||
export_data = %{
|
||||
columns: [
|
||||
%{key: "first_name", kind: :member_field, label: "Vorname"},
|
||||
%{key: "last_name", kind: :member_field, label: "Nachname"}
|
||||
],
|
||||
rows: [
|
||||
["Max", "Mustermann"],
|
||||
["Anna", "Schmidt"]
|
||||
],
|
||||
meta: %{
|
||||
generated_at: "2024-01-15T14:30:00Z",
|
||||
member_count: 2
|
||||
}
|
||||
}
|
||||
|
||||
result = MembersPDF.render(export_data)
|
||||
|
||||
assert {:ok, pdf_binary} = result
|
||||
assert is_binary(pdf_binary)
|
||||
assert String.starts_with?(pdf_binary, "%PDF")
|
||||
assert byte_size(pdf_binary) > 1000
|
||||
end
|
||||
|
||||
test "generates valid PDF with custom fields and computed fields" do
|
||||
export_data = %{
|
||||
columns: [
|
||||
%{key: "first_name", kind: :member_field, label: "Vorname"},
|
||||
%{key: "last_name", kind: :member_field, label: "Nachname"},
|
||||
%{key: "email", kind: :member_field, label: "E-Mail"},
|
||||
%{key: :membership_fee_status, kind: :computed, label: "Beitragsstatus"},
|
||||
%{
|
||||
key: "550e8400-e29b-41d4-a716-446655440000",
|
||||
kind: :custom_field,
|
||||
label: "Mitgliedsnummer",
|
||||
custom_field: %{
|
||||
id: "550e8400-e29b-41d4-a716-446655440000",
|
||||
name: "Mitgliedsnummer",
|
||||
value_type: :string
|
||||
}
|
||||
}
|
||||
],
|
||||
rows: [
|
||||
["Max", "Mustermann", "max@example.com", "paid", "M-2024-001"],
|
||||
["Anna", "Schmidt", "anna@example.com", "unpaid", "M-2024-002"]
|
||||
],
|
||||
meta: %{
|
||||
generated_at: "2024-01-15T14:30:00Z",
|
||||
member_count: 2
|
||||
}
|
||||
}
|
||||
|
||||
result = MembersPDF.render(export_data)
|
||||
|
||||
assert {:ok, pdf_binary} = result
|
||||
assert is_binary(pdf_binary)
|
||||
assert String.starts_with?(pdf_binary, "%PDF")
|
||||
end
|
||||
|
||||
test "maintains deterministic column and row order" do
|
||||
export_data = %{
|
||||
columns: [
|
||||
%{key: "first_name", kind: :member_field, label: "Vorname"},
|
||||
%{key: "last_name", kind: :member_field, label: "Nachname"},
|
||||
%{key: "email", kind: :member_field, label: "E-Mail"}
|
||||
],
|
||||
rows: [
|
||||
["Max", "Mustermann", "max@example.com"],
|
||||
["Anna", "Schmidt", "anna@example.com"],
|
||||
["Peter", "Müller", "peter@example.com"]
|
||||
],
|
||||
meta: %{
|
||||
generated_at: "2024-01-15T14:30:00Z",
|
||||
member_count: 3
|
||||
}
|
||||
}
|
||||
|
||||
# Render twice and verify identical output
|
||||
{:ok, pdf1} = MembersPDF.render(export_data)
|
||||
{:ok, pdf2} = MembersPDF.render(export_data)
|
||||
|
||||
assert pdf1 == pdf2
|
||||
assert String.starts_with?(pdf1, "%PDF")
|
||||
assert String.starts_with?(pdf2, "%PDF")
|
||||
end
|
||||
|
||||
test "returns error when template file is missing" do
|
||||
# Temporarily rename template to simulate missing file
|
||||
template_path =
|
||||
Path.join(Application.app_dir(:mv, "priv"), "pdf_templates/members_export.typ")
|
||||
|
||||
original_content = File.read!(template_path)
|
||||
File.rm(template_path)
|
||||
|
||||
export_data = %{
|
||||
columns: [%{key: "first_name", kind: :member_field, label: "Vorname"}],
|
||||
rows: [["Max"]],
|
||||
meta: %{generated_at: "2024-01-15T14:30:00Z", member_count: 1}
|
||||
}
|
||||
|
||||
result = MembersPDF.render(export_data)
|
||||
|
||||
assert {:error, {:template_not_found, _reason}} = result
|
||||
|
||||
# Restore template
|
||||
File.write!(template_path, original_content)
|
||||
end
|
||||
|
||||
test "handles empty rows gracefully" do
|
||||
export_data = %{
|
||||
columns: [
|
||||
%{key: "first_name", kind: :member_field, label: "Vorname"},
|
||||
%{key: "last_name", kind: :member_field, label: "Nachname"}
|
||||
],
|
||||
rows: [],
|
||||
meta: %{
|
||||
generated_at: "2024-01-15T14:30:00Z",
|
||||
member_count: 0
|
||||
}
|
||||
}
|
||||
|
||||
result = MembersPDF.render(export_data)
|
||||
|
||||
assert {:ok, pdf_binary} = result
|
||||
assert is_binary(pdf_binary)
|
||||
assert String.starts_with?(pdf_binary, "%PDF")
|
||||
end
|
||||
|
||||
test "handles many columns correctly" do
|
||||
# Test with 10 columns to ensure dynamic column width calculation works
|
||||
columns =
|
||||
Enum.map(1..10, fn i ->
|
||||
%{key: "field_#{i}", kind: :member_field, label: "Feld #{i}"}
|
||||
end)
|
||||
|
||||
export_data = %{
|
||||
columns: columns,
|
||||
rows: [Enum.map(1..10, &"Wert #{&1}")],
|
||||
meta: %{
|
||||
generated_at: "2024-01-15T14:30:00Z",
|
||||
member_count: 1
|
||||
}
|
||||
}
|
||||
|
||||
result = MembersPDF.render(export_data)
|
||||
|
||||
assert {:ok, pdf_binary} = result
|
||||
assert is_binary(pdf_binary)
|
||||
assert String.starts_with?(pdf_binary, "%PDF")
|
||||
end
|
||||
|
||||
test "creates and cleans up temp directory" do
|
||||
export_data = %{
|
||||
columns: [%{key: "first_name", kind: :member_field, label: "Vorname"}],
|
||||
rows: [["Max"]],
|
||||
meta: %{
|
||||
generated_at: "2024-01-15T14:30:00Z",
|
||||
member_count: 1
|
||||
}
|
||||
}
|
||||
|
||||
# Get temp base directory
|
||||
temp_base = System.tmp_dir!()
|
||||
|
||||
# Count temp directories before
|
||||
before_count =
|
||||
temp_base
|
||||
|> File.ls!()
|
||||
|> Enum.count(fn name -> String.starts_with?(name, "mv_pdf_export_") end)
|
||||
|
||||
result = MembersPDF.render(export_data)
|
||||
|
||||
assert {:ok, _pdf_binary} = result
|
||||
|
||||
# Wait a bit for cleanup (async cleanup might take a moment)
|
||||
Process.sleep(100)
|
||||
|
||||
# Count temp directories after
|
||||
after_count =
|
||||
temp_base
|
||||
|> File.ls!()
|
||||
|> Enum.count(fn name -> String.starts_with?(name, "mv_pdf_export_") end)
|
||||
|
||||
# Should have same or fewer temp dirs (cleanup should have run)
|
||||
assert after_count <= before_count + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -522,7 +522,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "export to CSV" do
|
||||
describe "export dropdown" do
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
|
|
@ -535,34 +535,135 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
%{member1: m1}
|
||||
end
|
||||
|
||||
test "export button is rendered when no selection and shows (all)", %{conn: conn} do
|
||||
test "export dropdown button is rendered when no selection and shows (all)", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Dropdown button should be present
|
||||
assert html =~ ~s(data-testid="export-dropdown")
|
||||
assert html =~ ~s(data-testid="export-dropdown-button")
|
||||
assert html =~ "Export"
|
||||
# Button text shows "all" when 0 selected (locale-dependent)
|
||||
assert html =~ "Export to CSV"
|
||||
assert html =~ "all" or html =~ "All"
|
||||
end
|
||||
|
||||
test "after select_member event export button shows (1)", %{conn: conn, member1: member1} do
|
||||
test "after select_member event export dropdown shows (1)", %{conn: conn, member1: member1} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
render_click(view, "select_member", %{"id" => member1.id})
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "Export to CSV"
|
||||
assert html =~ "Export"
|
||||
assert html =~ "(1)"
|
||||
end
|
||||
|
||||
test "form has correct action and payload hidden input", %{conn: conn} do
|
||||
test "dropdown opens and closes on click", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Initially closed
|
||||
refute has_element?(view, ~s([data-testid="export-dropdown-menu"]))
|
||||
|
||||
# Click to open
|
||||
view
|
||||
|> element(~s([data-testid="export-dropdown-button"]))
|
||||
|> render_click()
|
||||
|
||||
# Menu should be visible
|
||||
assert has_element?(view, ~s([data-testid="export-dropdown-menu"]))
|
||||
|
||||
# Click to close
|
||||
view
|
||||
|> element(~s([data-testid="export-dropdown-button"]))
|
||||
|> render_click()
|
||||
|
||||
# Menu should be hidden
|
||||
refute has_element?(view, ~s([data-testid="export-dropdown-menu"]))
|
||||
end
|
||||
|
||||
test "dropdown has click-away and ESC handlers", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element(~s([data-testid="export-dropdown-button"]))
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
assert has_element?(view, ~s([data-testid="export-dropdown-menu"]))
|
||||
|
||||
# Check that click-away handler is present
|
||||
assert html =~ ~s(phx-click-away="close_dropdown")
|
||||
# Check that ESC handler is present
|
||||
assert html =~ ~s(phx-window-keydown="close_dropdown")
|
||||
assert html =~ ~s(phx-key="Escape")
|
||||
end
|
||||
|
||||
test "dropdown menu contains CSV and PDF export links with correct payload", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
render_click(view, "toggle_dropdown", %{}, "export-dropdown")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check CSV link
|
||||
assert html =~ ~s(data-testid="export-csv-link")
|
||||
assert html =~ "/members/export.csv"
|
||||
assert html =~ ~s(name="payload")
|
||||
assert html =~ ~s(type="hidden")
|
||||
assert html =~ ~s(name="_csrf_token")
|
||||
|
||||
# Check PDF link
|
||||
assert html =~ ~s(data-testid="export-pdf-link")
|
||||
assert html =~ "/members/export.pdf"
|
||||
assert html =~ ~s(name="payload")
|
||||
assert html =~ ~s(type="hidden")
|
||||
assert html =~ ~s(name="_csrf_token")
|
||||
|
||||
# Both forms should have the same payload
|
||||
csv_form_payload = extract_payload_from_form(html, "/members/export.csv")
|
||||
pdf_form_payload = extract_payload_from_form(html, "/members/export.pdf")
|
||||
|
||||
assert csv_form_payload == pdf_form_payload
|
||||
assert csv_form_payload != nil
|
||||
end
|
||||
|
||||
test "dropdown has correct ARIA attributes", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Button should have aria-haspopup="menu"
|
||||
assert html =~ ~s(aria-haspopup="menu")
|
||||
# Button should have aria-expanded="false" when closed
|
||||
assert html =~ ~s(aria-expanded="false")
|
||||
# Button should have aria-controls pointing to menu
|
||||
assert html =~ ~s(aria-controls="export-dropdown-menu")
|
||||
|
||||
# Open dropdown
|
||||
render_click(view, "toggle_dropdown", %{}, "export-dropdown")
|
||||
|
||||
html = render(view)
|
||||
# Button should have aria-expanded="true" when open
|
||||
assert html =~ ~s(aria-expanded="true")
|
||||
# Menu should have role="menu"
|
||||
assert html =~ ~s(role="menu")
|
||||
end
|
||||
|
||||
# Helper to extract payload value from form HTML
|
||||
defp extract_payload_from_form(html, action_path) do
|
||||
case Regex.run(
|
||||
~r/<form[^>]*action="#{Regex.escape(action_path)}"[^>]*>.*?<input[^>]*name="payload"[^>]*value="([^"]+)"/s,
|
||||
html
|
||||
) do
|
||||
[_, payload] -> payload
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue