Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/phoenix_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1216,13 +1216,15 @@ defmodule Phoenix.Component do
* `:not_accepted` - The entry does not match the `:accept` MIME types
* `:external_client_failure` - When external upload fails
* `{:writer_failure, reason}` - When the custom writer fails with `reason`
* `reason` - When the custom validator fails with `reason`

## Examples

```elixir
defp upload_error_to_string(:too_large), do: "The file is too large"
defp upload_error_to_string(:not_accepted), do: "You have selected an unacceptable file type"
defp upload_error_to_string(:external_client_failure), do: "Something went terribly wrong"
defp upload_error_to_string(:custom_validator_error), do: "Custom validation error"
```

```heex
Expand Down
13 changes: 13 additions & 0 deletions lib/phoenix_live_view.ex
Original file line number Diff line number Diff line change
Expand Up @@ -851,13 +851,26 @@ defmodule Phoenix.LiveView do
temporary file for consumption. See the `Phoenix.LiveView.UploadWriter` docs
for custom usage.

* `:validator` - An optional 1-arity function for performing custom validation
on each upload entry. The function receives the upload entry and must return
either `:ok` or `{:error, reason}`. When an error tuple is returned, the
entry is marked as failed and the error is exposed as `reason` via `upload_errors/2`.

Raises when a previously allowed upload under the same name is still active.

## Examples

allow_upload(socket, :avatar, accept: ~w(.jpg .jpeg), max_entries: 2)
allow_upload(socket, :avatar, accept: :any)

allow_upload(socket, :avatar, validator: fn entry ->
if String.length(entry.client_name) > 100 do
{:error, :filename_too_long}
else
:ok
end
end)

For consuming files automatically as they are uploaded, you can pair `auto_upload: true` with
a custom progress function to consume the entries as they are completed. For example:

Expand Down
36 changes: 34 additions & 2 deletions lib/phoenix_live_view/upload_config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ defmodule Phoenix.LiveView.UploadConfig do
:errors,
:auto_upload?,
:progress_event,
:writer
:writer,
:validator
]}

defstruct name: nil,
Expand All @@ -99,7 +100,8 @@ defmodule Phoenix.LiveView.UploadConfig do
errors: [],
auto_upload?: false,
progress_event: nil,
writer: nil
writer: nil,
validator: nil

@type t :: %__MODULE__{
name: atom() | String.t(),
Expand All @@ -124,6 +126,7 @@ defmodule Phoenix.LiveView.UploadConfig do
auto_upload?: boolean(),
writer: (name :: atom() | String.t(), UploadEntry.t(), Phoenix.LiveView.Socket.t() ->
{module(), term()}),
validator: (UploadEntry.t() -> :ok | {:error, atom()}) | nil,
progress_event:
(name :: atom() | String.t(), UploadEntry.t(), Phoenix.LiveView.Socket.t() ->
{:noreply, Phoenix.LiveView.Socket.t()})
Expand Down Expand Up @@ -294,6 +297,24 @@ defmodule Phoenix.LiveView.UploadConfig do
fn _name, _entry, _socket -> {Phoenix.LiveView.UploadTmpFileWriter, []} end
end

validator =
case Keyword.fetch(opts, :validator) do
{:ok, func} when is_function(func, 1) ->
func

{:ok, other} ->
raise ArgumentError, """
invalid :validator value provided to allow_upload.

Only a 1-arity anonymous function is supported. Got:

#{inspect(other)}
"""

:error ->
fn _entry -> :ok end
end

%UploadConfig{
ref: random_ref,
name: name,
Expand All @@ -309,6 +330,7 @@ defmodule Phoenix.LiveView.UploadConfig do
chunk_timeout: chunk_timeout,
progress_event: progress_event,
writer: writer,
validator: validator,
auto_upload?: Keyword.get(opts, :auto_upload, false),
allowed?: true
}
Expand Down Expand Up @@ -558,6 +580,7 @@ defmodule Phoenix.LiveView.UploadConfig do
{:ok, entry}
|> validate_max_file_size(conf)
|> validate_accepted(conf)
|> call_validator(conf)
|> case do
{:ok, entry} ->
{:ok, put_valid_entry(conf, entry)}
Expand Down Expand Up @@ -632,6 +655,15 @@ defmodule Phoenix.LiveView.UploadConfig do
end
end

defp call_validator({:ok, entry}, %UploadConfig{validator: validator_fun}) do
case validator_fun.(entry) do
{:error, reason} -> {:error, reason}
_ -> {:ok, entry}
end
end

defp call_validator({:error, _} = error, _conf), do: error

defp recalculate_computed_fields(%UploadConfig{} = conf) do
recalculate_errors(conf)
end
Expand Down
39 changes: 32 additions & 7 deletions test/phoenix_live_view/upload/channel_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,20 @@ defmodule Phoenix.LiveView.UploadChannelTest do
end

defp opts_for_allow_upload(opts) do
case Keyword.fetch(opts, :progress) do
{:ok, progress} ->
Keyword.put(opts, :progress, fn _, entry, socket ->
apply(__MODULE__, progress, [entry, socket])
end)
opts =
case Keyword.fetch(opts, :progress) do
{:ok, progress} ->
Keyword.put(opts, :progress, fn _, entry, socket ->
apply(__MODULE__, progress, [entry, socket])
end)

:error ->
opts
end

:error ->
opts
case Keyword.fetch(opts, :validator_response) do
{:ok, response} -> Keyword.put(opts, :validator, fn _ -> response end)
:error -> opts
end
end

Expand Down Expand Up @@ -406,6 +412,25 @@ defmodule Phoenix.LiveView.UploadChannelTest do
assert {:error, [[_ref, :too_large]]} = render_upload(avatar, "foo.jpeg")
end

@tag allow: [
max_entries: 1,
chunk_size: 20,
accept: :any,
max_file_size: 100,
validator_response: {:error, :custom_validation_error}
]
test "render_change error with validator failure upload", %{lv: lv} do
avatar = file_input(lv, "form", :avatar, [%{name: "foo.jpeg", content: "ok"}])

assert lv
|> form("form", user: %{})
|> render_change(avatar) =~
"entry_error::custom_validation_error"

assert {:error, [[_ref, :custom_validation_error]]} =
render_upload(avatar, "foo.jpeg")
end

@tag allow: [max_entries: 1, chunk_size: 20, accept: :any, auto_upload: true]
test "render_upload too many files with auto_upload", %{lv: lv} do
avatar =
Expand Down
26 changes: 26 additions & 0 deletions test/phoenix_live_view/upload/config_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,32 @@ defmodule Phoenix.LiveView.UploadConfigTest do

assert %UploadConfig{max_file_size: 10_000_000} = socket.assigns.uploads.avatar
end

test "raises when invalid :validator provided" do
assert_raise ArgumentError, ~r/invalid :validator value provided to allow_upload/, fn ->
LiveView.allow_upload(build_socket(), :avatar, accept: :any, validator: 0)
end

assert_raise ArgumentError, ~r/invalid :validator value provided to allow_upload/, fn ->
LiveView.allow_upload(build_socket(), :avatar, accept: :any, validator: "bad")
end

assert_raise ArgumentError, ~r/invalid :validator value provided to allow_upload/, fn ->
wrong_arity_fun = fn _, _ -> :ok end
LiveView.allow_upload(build_socket(), :avatar, accept: :any, validator: wrong_arity_fun)
end
end

test "supports optional :validator" do
socket =
LiveView.allow_upload(build_socket(), :avatar,
accept: :any,
validator: fn _ -> :ok end
)

%UploadConfig{validator: validator_fun} = socket.assigns.uploads.avatar
assert is_function(validator_fun, 1)
end
end

describe "disallow_upload/2" do
Expand Down
36 changes: 35 additions & 1 deletion test/phoenix_live_view/upload/external_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ defmodule Phoenix.LiveView.UploadExternalTest do
opts
end

opts =
case Keyword.fetch(opts, :validator_response) do
{:ok, response} -> Keyword.put(opts, :validator, fn _ -> response end)
:error -> opts
end

{:ok, lv} = mount_lv(fn socket -> Phoenix.LiveView.allow_upload(socket, :avatar, opts) end)

{:ok, lv: lv}
Expand Down Expand Up @@ -81,7 +87,13 @@ defmodule Phoenix.LiveView.UploadExternalTest do
assert render_upload(avatar, "foo1.jpeg", 1) =~ "relative path:some/path/to/foo1.jpeg"
end

@tag allow: [max_entries: 2, chunk_size: 20, accept: :any, external: :preflight]
@tag allow: [
max_entries: 2,
chunk_size: 20,
accept: :any,
external: :preflight,
validator_response: :ok
]
test "external upload invokes preflight per entry", %{lv: lv} do
avatar =
file_input(lv, "form", :avatar, [
Expand Down Expand Up @@ -165,6 +177,28 @@ defmodule Phoenix.LiveView.UploadExternalTest do
assert {:error, :not_allowed} = render_upload(avatar, "foo2.jpeg", 1)
end

@tag allow: [
max_entries: 1,
max_file_size: 100,
auto_upload: true,
accept: :any,
external: :preflight,
validator_response: {:error, :custom_validation_error}
]
test "custom validator returns error", %{lv: lv} do
avatar = file_input(lv, "form", :avatar, [%{name: "foo.jpeg", content: "ok"}])

html =
lv
|> form("form", user: %{})
|> render_change(avatar)

assert html =~ "foo.jpeg:0%"

assert {:error, [[_, %{reason: :custom_validation_error}]]} =
render_upload(avatar, "foo.jpeg", 1)
end

def bad_preflight(%LiveView.UploadEntry{} = _entry, socket), do: {:ok, %{}, socket}

@tag allow: [max_entries: 1, chunk_size: 20, accept: :any, external: :bad_preflight]
Expand Down
Loading