From fa605067db03a7fca28b72b212a88a2e3a45f240 Mon Sep 17 00:00:00 2001 From: Dzung Nguyen Date: Thu, 22 Feb 2024 22:10:14 +0700 Subject: [PATCH 1/6] Customize params macro from TypedStruct --- lib/orange_cms/shared/def_params.ex | 201 ++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 lib/orange_cms/shared/def_params.ex diff --git a/lib/orange_cms/shared/def_params.ex b/lib/orange_cms/shared/def_params.ex new file mode 100644 index 0000000..d290a16 --- /dev/null +++ b/lib/orange_cms/shared/def_params.ex @@ -0,0 +1,201 @@ +# result: + +# - struct +# - field list +# - functions +# + new(map) +# + to_map() + +defmodule CreateParams do + @moduledoc """ + defparams CreateOrderParams do + field :name, :string, required: true + field :email, :string, required: true, pattern: ~r/.+@gmail.com/ + field :phone, :string, pattern: ~r/\d{3}-\d{3}-\d{4}/ + field :address, :string, default: nil + end + """ + @enforce_keys [:name, :email] + defstruct name: nil, email: nil, phone: nil, address: nil + + @field_list [ + name: [type: :string, required: true], + email: [type: :string, required: true, pattern: ~r/./], + phone: [type: :string, pattern: ~r/./], + address: [type: :string, default: nil] + ] + + def new(map) do + struct(__MODULE__, map) + end + + # def cast(params) when is_map(params) do + # Tarams.cast(params, @field_list) + # end + + def to_map(%CreateParams{} = params) do + Map.from_struct(params) + end + + def __schema__(:fields) do + @field_list + end +end + +defmodule OrangeCms.Params do + @moduledoc """ + Borrow from https://github.com/ejpcmac/typed_struct/blob/main/lib/typed_struct.ex + """ + @accumulating_attrs [ + :ts_fields, + :ts_types, + :ts_enforce_keys + ] + + @attrs_to_delete @accumulating_attrs + + @doc false + defmacro __using__(_) do + quote do + import OrangeCms.Params, only: [defparams: 1, defparams: 2] + end + end + + @doc """ + Defines a typed struct. + + Inside a `defparams` block, each field is defined through the `field/2` + macro. + + ## Options + + * `enforce` - if set to true, sets `enforce: true` to all fields by default. + This can be overridden by setting `enforce: false` or a default value on + individual fields. + * `opaque` - if set to true, creates an opaque type for the struct. + * `module` - if set, creates the struct in a submodule named `module`. + + ## Examples + + defmodule MyStruct do + use OrangeCms.Params + + defparams do + field :field_one, :string + field :field_two, :integer, required: true + field :field_three, :boolean, required: true + field :field_four, :atom, default: :hey + end + end + + You can create the struct in a submodule instead: + + defmodule MyModule do + use OrangeCms.Params + + defparams Struct do + field :field_one, :string + field :field_two, :integer, required: true + field :field_three, :boolean, required: true + field :field_four, :atom, default: :hey + end + end + """ + defmacro defparams(module \\ nil, do: block) do + ast = OrangeCms.Params.__typedstruct__(block) + + case module do + nil -> + quote do + # Create a lexical scope. + (fn -> unquote(ast) end).() + end + + module -> + quote do + defmodule unquote(module) do + unquote(ast) + end + end + end + end + + @doc false + def __typedstruct__(block) do + quote do + import OrangeCms.Params + + Enum.each(unquote(@accumulating_attrs), fn attr -> + Module.register_attribute(__MODULE__, attr, accumulate: true) + end) + + @before_compile {unquote(__MODULE__), :__before_compile__} + unquote(block) + + @enforce_keys @ts_enforce_keys + defstruct @ts_fields + + OrangeCms.Params.__type__(@ts_types) + end + end + + @doc false + defmacro __type__(types) do + quote bind_quoted: [types: types] do + @type t() :: %__MODULE__{unquote_splicing(types)} + end + end + + @doc """ + Defines a field in a typed struct. + + ## Example + + # A field named :example of type String.t() + field :example, String.t() + + ## Options + + * `default` - sets the default value for the field + * `enforce` - if set to true, enforces the field and makes its type + non-nullable + """ + defmacro field(name, type, opts \\ []) do + quote bind_quoted: [name: name, type: Macro.escape(type), opts: opts] do + OrangeCms.Params.__field__(name, type, opts, __ENV__) + end + end + + @doc false + def __field__(name, type, opts, %Macro.Env{module: mod}) when is_atom(name) do + if mod |> Module.get_attribute(:ts_fields) |> Keyword.has_key?(name) do + raise ArgumentError, "the field #{inspect(name)} is already set" + end + + has_default? = Keyword.has_key?(opts, :default) + + enforce? = + if is_nil(opts[:required]), + do: not has_default?, + else: opts[:required] == true + + nullable? = not has_default? and not enforce? + + Module.put_attribute(mod, :ts_fields, {name, opts}) + Module.put_attribute(mod, :ts_types, {name, type_for(type, nullable?)}) + if enforce?, do: Module.put_attribute(mod, :ts_enforce_keys, name) + end + + def __field__(name, _type, _opts, _env) do + raise ArgumentError, "a field name must be an atom, got #{inspect(name)}" + end + + # Makes the type nullable if the key is not enforced. + defp type_for(type, false), do: type + defp type_for(type, _), do: quote(do: unquote(type) | nil) + + @doc false + defmacro __before_compile__(%Macro.Env{module: module}) do + Enum.each(unquote(@attrs_to_delete), &Module.delete_attribute(module, &1)) + end +end From 40e5199459f96db3812aa54c9a5c9b85884b546b Mon Sep 17 00:00:00 2001 From: Dzung Nguyen Date: Fri, 23 Feb 2024 10:56:31 +0700 Subject: [PATCH 2/6] Implement new way to define params --- lib/orange_cms/shared/def_params.ex | 86 +++++++++++++---------------- mix.exs | 3 +- mix.lock | 2 + 3 files changed, 42 insertions(+), 49 deletions(-) diff --git a/lib/orange_cms/shared/def_params.ex b/lib/orange_cms/shared/def_params.ex index d290a16..abf882f 100644 --- a/lib/orange_cms/shared/def_params.ex +++ b/lib/orange_cms/shared/def_params.ex @@ -1,47 +1,3 @@ -# result: - -# - struct -# - field list -# - functions -# + new(map) -# + to_map() - -defmodule CreateParams do - @moduledoc """ - defparams CreateOrderParams do - field :name, :string, required: true - field :email, :string, required: true, pattern: ~r/.+@gmail.com/ - field :phone, :string, pattern: ~r/\d{3}-\d{3}-\d{4}/ - field :address, :string, default: nil - end - """ - @enforce_keys [:name, :email] - defstruct name: nil, email: nil, phone: nil, address: nil - - @field_list [ - name: [type: :string, required: true], - email: [type: :string, required: true, pattern: ~r/./], - phone: [type: :string, pattern: ~r/./], - address: [type: :string, default: nil] - ] - - def new(map) do - struct(__MODULE__, map) - end - - # def cast(params) when is_map(params) do - # Tarams.cast(params, @field_list) - # end - - def to_map(%CreateParams{} = params) do - Map.from_struct(params) - end - - def __schema__(:fields) do - @field_list - end -end - defmodule OrangeCms.Params do @moduledoc """ Borrow from https://github.com/ejpcmac/typed_struct/blob/main/lib/typed_struct.ex @@ -85,6 +41,7 @@ defmodule OrangeCms.Params do field :field_two, :integer, required: true field :field_three, :boolean, required: true field :field_four, :atom, default: :hey + field :update_time, :naive_datetime, default: &NaiveDateTime.utc_now/0 end end @@ -93,33 +50,66 @@ defmodule OrangeCms.Params do defmodule MyModule do use OrangeCms.Params - defparams Struct do + defparams Comment do + field :user_id, :integer, required: true + field :content, :string, required: true + end + + defparams Post do field :field_one, :string field :field_two, :integer, required: true field :field_three, :boolean, required: true - field :field_four, :atom, default: :hey + field :field_four, :string, default: "hello" + field :update_time, :naive_datetime, default: &NaiveDateTime.utc_now/0 + field :comment, Comment, required: true end end + + MyModule.Post.cast(%{field_two: 1, field_three: true, comment: %{user_id: 1, content: "hello"}}) + """ defmacro defparams(module \\ nil, do: block) do ast = OrangeCms.Params.__typedstruct__(block) + method_ast = OrangeCms.Params.__default_functions__() case module do nil -> quote do # Create a lexical scope. (fn -> unquote(ast) end).() + unquote(method_ast) end module -> quote do defmodule unquote(module) do unquote(ast) + + unquote(method_ast) end end end end + def __default_functions__ do + quote do + def new(map) do + struct(__MODULE__, map) + end + + def cast(params) when is_map(params) do + case Tarams.cast(params, @ts_fields) do + {:ok, params} -> {:ok, new(params)} + {:error, errors} -> {:error, errors} + end + end + + def __schema__(:fields) do + @ts_fields + end + end + end + @doc false def __typedstruct__(block) do quote do @@ -161,7 +151,7 @@ defmodule OrangeCms.Params do non-nullable """ defmacro field(name, type, opts \\ []) do - quote bind_quoted: [name: name, type: Macro.escape(type), opts: opts] do + quote bind_quoted: [name: name, type: type, opts: opts] do OrangeCms.Params.__field__(name, type, opts, __ENV__) end end @@ -181,7 +171,7 @@ defmodule OrangeCms.Params do nullable? = not has_default? and not enforce? - Module.put_attribute(mod, :ts_fields, {name, opts}) + Module.put_attribute(mod, :ts_fields, {name, [{:type, type} | opts]}) Module.put_attribute(mod, :ts_types, {name, type_for(type, nullable?)}) if enforce?, do: Module.put_attribute(mod, :ts_enforce_keys, name) end diff --git a/mix.exs b/mix.exs index 40f9d91..640305e 100644 --- a/mix.exs +++ b/mix.exs @@ -64,7 +64,8 @@ defmodule OrangeCms.MixProject do {:styler, "~> 0.7", only: [:dev, :test], runtime: false}, {:tailwind_formatter, "~> 0.3", only: [:dev, :test], runtime: false}, {:scrivener_ecto, "~> 2.7"}, - {:sobelow, "~> 0.13", only: [:dev, :test], runtime: false} + {:sobelow, "~> 0.13", only: [:dev, :test], runtime: false}, + {:tarams, "~> 1.8"} ] end diff --git a/mix.lock b/mix.lock index 2b229f5..978069b 100644 --- a/mix.lock +++ b/mix.lock @@ -58,12 +58,14 @@ "tails": {:hex, :tails, "0.1.5", "e9ede056fa7d2b9c01ded85797994563f09806702e6a0f2c0bc2b7403c02aa3c", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e93124476f57652417e9245752fb8fd868b00a5d550e1ca1d9794bac40c47c22"}, "tailwind": {:hex, :tailwind, "0.2.1", "83d8eadbe71a8e8f67861fe7f8d51658ecfb258387123afe4d9dc194eddc36b0", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "e8a13f6107c95f73e58ed1b4221744e1eb5a093cd1da244432067e19c8c9a277"}, "tailwind_formatter": {:hex, :tailwind_formatter, "0.3.6", "f3b02687a79a99106f2cee604d36561091ab5b9c9d16a97ae5901d91b3357047", [:mix], [{:phoenix_live_view, ">= 0.17.6", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "3a0d75dad1700f9fa9394185c4ce0eb0eff2b1a0eb9aef66b4b382eae657bded"}, + "tarams": {:hex, :tarams, "1.8.0", "93ab48ded14ffb4d9c5355f4e2ac03747aba8f267661bffa1c333b21bd3a0471", [:mix], [{:decimal, "~> 2.1", [hex: :decimal, repo: "hexpm", optional: false]}, {:valdi, "~> 0.4", [hex: :valdi, repo: "hexpm", optional: false]}], "hexpm", "2be77905e8f34f2c252eff970c3e281a4caea297f4e469e2bc7a5d7509bab4bc"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, "tentacat": {:hex, :tentacat, "2.2.0", "ed2f137c3f64a787cd278ccb1ddbb9e5b696c9c09e3c89d559fffa64ad1494b8", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "b4ca367af4769774c7dd24a53738f20603012c03715be6c23d8e22c220ee8c07"}, "twix": {:hex, :twix, "0.3.0", "619f8906914c4c145b9a969123b5adf32f1ce7f8178955b60492f3e68c0ca9f6", [:mix], [], "hexpm", "672e0c137c556a1f39d24189f1a5718067afd44e71f69addd0ea4dd1d745ba13"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "valdi": {:hex, :valdi, "0.4.0", "ccc81333f3624cf6e68bab2c31ebac831b983a96809b44e19eb367a13e953f14", [:mix], [{:decimal, "~> 2.1", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "33dd25b2d7eb3870dc6ca1d78389f543ecba9c02197be38a0cf3b6773f38d716"}, "websock": {:hex, :websock, "0.5.2", "b3c08511d8d79ed2c2f589ff430bd1fe799bb389686dafce86d28801783d8351", [:mix], [], "hexpm", "925f5de22fca6813dfa980fb62fd542ec43a2d1a1f83d2caec907483fe66ff05"}, "websock_adapter": {:hex, :websock_adapter, "0.5.3", "4908718e42e4a548fc20e00e70848620a92f11f7a6add8cf0886c4232267498d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "cbe5b814c1f86b6ea002b52dd99f345aeecf1a1a6964e209d208fb404d930d3d"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, From 9008a181d99a0d021636013d4840e0d5b8253d10 Mon Sep 17 00:00:00 2001 From: Dzung Nguyen Date: Fri, 23 Feb 2024 22:04:56 +0700 Subject: [PATCH 3/6] Add value module --- lib/orange_cms/shared/def_params.ex | 8 -- lib/orange_cms/shared/value.ex | 135 ++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 lib/orange_cms/shared/value.ex diff --git a/lib/orange_cms/shared/def_params.ex b/lib/orange_cms/shared/def_params.ex index abf882f..6c70a6a 100644 --- a/lib/orange_cms/shared/def_params.ex +++ b/lib/orange_cms/shared/def_params.ex @@ -23,14 +23,6 @@ defmodule OrangeCms.Params do Inside a `defparams` block, each field is defined through the `field/2` macro. - ## Options - - * `enforce` - if set to true, sets `enforce: true` to all fields by default. - This can be overridden by setting `enforce: false` or a default value on - individual fields. - * `opaque` - if set to true, creates an opaque type for the struct. - * `module` - if set, creates the struct in a submodule named `module`. - ## Examples defmodule MyStruct do diff --git a/lib/orange_cms/shared/value.ex b/lib/orange_cms/shared/value.ex new file mode 100644 index 0000000..d4a61de --- /dev/null +++ b/lib/orange_cms/shared/value.ex @@ -0,0 +1,135 @@ +defmodule OrangeCms.Value do + @moduledoc """ + Module to allow better Value composition. With this module we're able to + compose complex structures faster and simpler. + + A Value's base format can only be a List or a Map. + """ + + @doc """ + Initiate a Value base format as a List + + ## Examples + iex> init_with_list() + [] + """ + def init_with_list, do: [] + + @doc """ + Initiate a Value base format as a Map + + ## Examples + iex> init_with_map() + %{} + """ + def init_with_map, do: %{} + + @doc """ + Initiate a Value based on a pre-existing Struct. + + ## Examples + iex> country = %Country{name: "Portugal", region: "Europe", slug: "slug", code: "code"} + %Country{name: "Portugal", region: "Europe", slug: "slug", code: "code"} + iex> init(country) + %{name: "Portugal", region: "Europe", slug: "slug", code: "code"} + + iex> init(%{a: 1}) + %{a: 1} + iex> init([1, 2, 3]) + [1, 2, 3] + """ + def init(%{__struct__: _} = value) do + value + |> Map.from_struct() + |> Map.drop([:__meta__, :__struct__]) + end + + # Initiate a Value based on a pre-existing Map or List. + def init(value) do + value + end + + @doc """ + Remove specified keys from a Value. + + ## Examples + iex> response = init(%{a: 1, b: 2}) + %{a: 1, b: 2} + iex> except(response, [:a]) + %{b: 2} + """ + def except(value, keys) when is_map(value), do: Map.drop(value, keys) + + @doc """ + Return only specified keys from a Value. + + ## Examples + iex> response = init(%{a: 1, b: 2}) + %{a: 1, b: 2} + iex> only(response, [:a]) + %{a: 1} + """ + def only(value, keys) when is_map(value), do: Map.take(value, keys) + + @doc """ + Add an item to a Value list. + + ## Examples + iex> response = init([1, 2, 3]) + [1, 2, 3] + iex> add(response, 4) + [4, 1, 2, 3] + + iex> response = init(%{a: 1, b: 2}) + %{a: 1, b: 2} + iex> add(response, %{c: 3}) + %{a: 1, b: 2, c: 3} + iex> add(response, c: 3) + %{a: 1, b: 2, c: 3} + """ + def add(value, entry) when is_list(value), do: [entry | value] + + # Add an item to a value map. Accepts a Map or a simple keyword list. + def add(value, entry) when is_map(value) do + Enum.reduce(entry, value, fn {key, key_value}, acc -> + Map.put(acc, key, key_value) + end) + end + + @doc """ + Removes keys with `nil` values from the map + """ + def compact(map) do + map + |> Enum.reject(fn {_, value} -> is_nil(value) end) + |> Map.new() + end + + @doc """ + Modifies provided key by applying provided function. + If key is not present it won't be updated, no exception be raised. + + ## Examples + iex> response = init(%{a: 1, b: 2}) + %{a: 1, b: 2} + iex> modify(response, :b, fn val -> val * 2 end) + %{a: 1, b: 4} + iex> modify(response, :c, fn val -> val * 2 end) + %{a: 1, b: 2} + """ + def modify(data, key, fun) when is_map(data) and is_function(fun) do + data + |> Map.update(key, nil, fun) + |> compact() + end + + # @doc """ + # build associations with their own 'Value' modules when their are present, + # avoiding `nil` or unloaded structs + # """ + # # def build_assoc(value_module, assoc, fields \\ nil) + # def build_assoc(_value_module, nil, _), do: nil + # def build_assoc(_value_module, %Ecto.Association.NotLoaded{}, _), do: nil + # def build_assoc(value_module, assoc, nil), do: value_module.build(assoc) + # def build_assoc(value_module, assoc, fields), do: value_module.build(assoc, fields) +end From 569cb64d62988c1b61786b6231c7c009e611fbcb Mon Sep 17 00:00:00 2001 From: Dzung Nguyen Date: Sun, 25 Feb 2024 13:06:48 +0700 Subject: [PATCH 4/6] Add new core module - Context for provide common context - ParamsError implementing HTM FormData --- lib/orange_cms.ex | 6 ++ .../commands/create_project_command.ex | 11 ++- .../projects/{ => models}/project.ex | 0 .../projects/{ => models}/project_member.ex | 0 .../projects/params/create_project_params.ex | 9 ++ .../usecases/create_project_usecase.ex | 5 +- lib/orange_cms/shared/context.ex | 51 +++++++++++ lib/orange_cms/shared/def_params.ex | 6 +- lib/orange_cms/shared/params_error.ex | 85 +++++++++++++++++++ lib/orange_cms/shared/value.ex | 4 +- .../live/project_live/form_component.ex | 24 +++--- test/orange_cms/projects_test.exs | 12 +-- test/support/fixtures/projects_fixtures.ex | 4 +- 13 files changed, 191 insertions(+), 26 deletions(-) rename lib/orange_cms/projects/{ => models}/project.ex (100%) rename lib/orange_cms/projects/{ => models}/project_member.ex (100%) create mode 100644 lib/orange_cms/projects/params/create_project_params.ex create mode 100644 lib/orange_cms/shared/context.ex create mode 100644 lib/orange_cms/shared/params_error.ex diff --git a/lib/orange_cms.ex b/lib/orange_cms.ex index d48c6dd..868faca 100644 --- a/lib/orange_cms.ex +++ b/lib/orange_cms.ex @@ -52,6 +52,12 @@ defmodule OrangeCms do end end + def param do + quote do + use OrangeCms.Params + end + end + @doc """ When used, dispatch to the appropriate context/schema/service/repo/finder """ diff --git a/lib/orange_cms/projects/commands/create_project_command.ex b/lib/orange_cms/projects/commands/create_project_command.ex index 54f5b90..7ba0022 100644 --- a/lib/orange_cms/projects/commands/create_project_command.ex +++ b/lib/orange_cms/projects/commands/create_project_command.ex @@ -4,14 +4,19 @@ defmodule OrangeCms.Projects.CreateProjectCommand do """ use OrangeCms, :command + alias OrangeCms.Projects.CreateProjectParams alias OrangeCms.Projects.Project + alias OrangeCms.Value - def call(attrs, creator) do + def call(%CreateProjectParams{} = params, creator) do # add default member - attrs = Map.put(attrs, :project_members, [%{user_id: creator.id, role: :admin, is_owner: true}]) + params = + params + |> Value.new() + |> Map.put(:project_members, [%{user_id: creator.id, role: :admin, is_owner: true}]) %Project{owner_id: creator.id} - |> Project.changeset(attrs) + |> Project.changeset(params) |> Project.change_members() |> Repo.insert() end diff --git a/lib/orange_cms/projects/project.ex b/lib/orange_cms/projects/models/project.ex similarity index 100% rename from lib/orange_cms/projects/project.ex rename to lib/orange_cms/projects/models/project.ex diff --git a/lib/orange_cms/projects/project_member.ex b/lib/orange_cms/projects/models/project_member.ex similarity index 100% rename from lib/orange_cms/projects/project_member.ex rename to lib/orange_cms/projects/models/project_member.ex diff --git a/lib/orange_cms/projects/params/create_project_params.ex b/lib/orange_cms/projects/params/create_project_params.ex new file mode 100644 index 0000000..8d2822c --- /dev/null +++ b/lib/orange_cms/projects/params/create_project_params.ex @@ -0,0 +1,9 @@ +defmodule OrangeCms.Projects.CreateProjectParams do + @moduledoc false + use OrangeCms.Params + + defparams do + field :name, :string, required: true + field :type, :any, default: "github", in: ["github", "headless_cms", :github, :headless_cms] + end +end diff --git a/lib/orange_cms/projects/usecases/create_project_usecase.ex b/lib/orange_cms/projects/usecases/create_project_usecase.ex index b4f7f53..2abc946 100644 --- a/lib/orange_cms/projects/usecases/create_project_usecase.ex +++ b/lib/orange_cms/projects/usecases/create_project_usecase.ex @@ -4,9 +4,10 @@ defmodule OrangeCms.Projects.CreateProjectUsecase do Create a project with given params Add creator as the project owner """ + alias OrangeCms.Projects.CreateProjectParams - def call(attrs, actor) do - attrs + def call(%CreateProjectParams{} = params, actor) do + params |> OrangeCms.Projects.CreateProjectCommand.call(actor) |> handle_result() end diff --git a/lib/orange_cms/shared/context.ex b/lib/orange_cms/shared/context.ex new file mode 100644 index 0000000..756eedb --- /dev/null +++ b/lib/orange_cms/shared/context.ex @@ -0,0 +1,51 @@ +defmodule OrangeCms.Context do + @moduledoc """ + This module is the context for the OrangeCms application + + A context is a struct which hold commonly data that business logic needs and should aware of. + + Details of the context: + - actor: The user who is performing the action + - project: The project that the action is performed on + - resource: The resource that the action is performed on + - extra: Extra data that the action needs + """ + defstruct [:actor, :project, :resource, extra: %{}] + + @doc """ + Create a new empty context + """ + @spec new() :: %__MODULE__{} + def new do + %__MODULE__{} + end + + @doc """ + Create a new context with given actor, project, resource and extra data + """ + @spec new(actor :: any, project :: any, resource :: any, extra :: map) :: %__MODULE__{} + def new(actor, project, resource, extra \\ %{}) do + %__MODULE__{actor: actor, project: project, resource: resource, extra: extra} + end + + @doc """ + Create new context with data provided in a map or keyword list + """ + @spec new(attrs :: map) :: %__MODULE__{} + def new(attrs) when is_map(attrs) or is_list(attrs) do + struct(__MODULE__, attrs) + end + + @doc """ + Get the value of a key from the context. Only accepts atoms as keys + + ## Examples + + iex> context = OrangeCms.Context.new(actor: use, project: project) + iex> OrangeCms.Context.put(context, :resource, post) + """ + @spec put(context :: %__MODULE__{}, key :: atom, value :: any) :: %__MODULE__{} + def put(context, key, value) do + struct(context, [{key, value}]) + end +end diff --git a/lib/orange_cms/shared/def_params.ex b/lib/orange_cms/shared/def_params.ex index 6c70a6a..7b089d7 100644 --- a/lib/orange_cms/shared/def_params.ex +++ b/lib/orange_cms/shared/def_params.ex @@ -85,6 +85,10 @@ defmodule OrangeCms.Params do def __default_functions__ do quote do + def new(struct) when is_struct(struct) do + struct(__MODULE__, Map.from_struct(struct)) + end + def new(map) do struct(__MODULE__, map) end @@ -92,7 +96,7 @@ defmodule OrangeCms.Params do def cast(params) when is_map(params) do case Tarams.cast(params, @ts_fields) do {:ok, params} -> {:ok, new(params)} - {:error, errors} -> {:error, errors} + {:error, errors} -> {:error, %OrangeCms.ParamsError{errors: errors, data: params}} end end diff --git a/lib/orange_cms/shared/params_error.ex b/lib/orange_cms/shared/params_error.ex new file mode 100644 index 0000000..947ad5d --- /dev/null +++ b/lib/orange_cms/shared/params_error.ex @@ -0,0 +1,85 @@ +defmodule OrangeCms.ParamsError do + @moduledoc """ + This module defines structs for the params error + """ + defstruct data: %{}, errors: [] + + defimpl Phoenix.HTML.FormData do + @impl true + def to_form(error, opts) do + %Phoenix.HTML.Form{ + source: error, + impl: __MODULE__, + id: opts[:id], + name: opts[:as], + errors: error.errors, + data: %{}, + params: error.data, + hidden: [], + options: opts + } + end + + @impl true + def to_form(error, form, field, options) do + {prepend, options} = Keyword.pop(options, :prepend, []) + {append, options} = Keyword.pop(options, :append, []) + {name, options} = Keyword.pop(options, :as) + {id, options} = Keyword.pop(options, :id) + + id = to_string(id || form.id <> "_#{field}") + name = to_string(name || form.name <> "[#{field}]") + + case error.data do + %{^field => values} when is_list(values) -> + Enum.map( + prepend ++ values ++ append, + &%Phoenix.HTML.Form{ + source: &1, + impl: __MODULE__, + id: id, + name: name, + errors: [], + data: &1, + params: &1, + options: options + } + ) + + %{^field => value} when is_map(value) -> + [ + %Phoenix.HTML.Form{ + source: value, + impl: __MODULE__, + id: id, + name: name, + errors: [], + data: value, + params: value, + options: options + } + ] + + %{^field => _value} -> + raise "data.#{field} is not a list nor a map" + end + end + + @impl true + def input_value(data, _form, field) do + case data do + %{^field => value} -> + value + + _missing -> + raise "Invalid filter form field #{field}." + end + end + + @impl true + def input_type(_data, _form, _field), do: nil + + @impl true + def input_validations(_, _, _), do: [] + end +end diff --git a/lib/orange_cms/shared/value.ex b/lib/orange_cms/shared/value.ex index d4a61de..1a2630b 100644 --- a/lib/orange_cms/shared/value.ex +++ b/lib/orange_cms/shared/value.ex @@ -38,14 +38,14 @@ defmodule OrangeCms.Value do iex> init([1, 2, 3]) [1, 2, 3] """ - def init(%{__struct__: _} = value) do + def new(%{__struct__: _} = value) do value |> Map.from_struct() |> Map.drop([:__meta__, :__struct__]) end # Initiate a Value based on a pre-existing Map or List. - def init(value) do + def new(value) do value end diff --git a/lib/orange_cms_web/live/project_live/form_component.ex b/lib/orange_cms_web/live/project_live/form_component.ex index ddc82e3..31db4d3 100644 --- a/lib/orange_cms_web/live/project_live/form_component.ex +++ b/lib/orange_cms_web/live/project_live/form_component.ex @@ -55,22 +55,22 @@ defmodule OrangeCmsWeb.ProjectLive.FormComponent do end defp save_project(socket, :new, project_params) do - case Projects.create_project(project_params, OrangeCms.get_actor()) do - {:ok, project} -> - notify_parent({:saved, project}) + with {:ok, parsed_params} <- Projects.CreateProjectParams.cast(project_params), + {:ok, project} <- Projects.create_project(parsed_params, OrangeCms.get_actor()) do + notify_parent({:saved, project}) - {:noreply, - socket - |> put_flash(:info, "Project created successfully") - |> push_navigate(to: ~p"/p/#{project.code}")} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign_form(socket, changeset)} + {:noreply, + socket + |> put_flash(:info, "Project created successfully") + |> push_navigate(to: ~p"/p/#{project.code}")} + else + {:error, error} -> + {:noreply, assign_form(socket, error)} end end - defp assign_form(socket, %Ecto.Changeset{} = changeset) do - assign(socket, :form, to_form(changeset)) + defp assign_form(socket, changeset) do + assign(socket, :form, to_form(changeset, as: "project")) end defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) diff --git a/test/orange_cms/projects_test.exs b/test/orange_cms/projects_test.exs index 838d6bd..3b5b6eb 100644 --- a/test/orange_cms/projects_test.exs +++ b/test/orange_cms/projects_test.exs @@ -32,20 +32,22 @@ defmodule OrangeCms.ProjectsTest do valid_attrs = %{ github_config: %{}, name: "some name", - setup_completed: true, type: :github } - assert {:ok, %Project{} = project} = Projects.create_project(valid_attrs, user) + {:ok, params} = OrangeCms.Projects.CreateProjectParams.cast(valid_attrs) + + assert {:ok, %Project{} = project} = Projects.create_project(params, user) assert project.name == "some name" - assert project.setup_completed == true assert project.type == :github end test "create_project/1 with invalid data returns error changeset" do - user = random_user_fixture() + # user = random_user_fixture() + + assert {:error, _} = OrangeCms.Projects.CreateProjectParams.cast(@invalid_attrs) - assert {:error, %Ecto.Changeset{}} = Projects.create_project(@invalid_attrs, user) + # assert {:error, %Ecto.Changeset{}} = Projects.create_project(@invalid_attrs, user) end test "update_project/2 with valid data updates the project" do diff --git a/test/support/fixtures/projects_fixtures.ex b/test/support/fixtures/projects_fixtures.ex index 21863c2..5f7caea 100644 --- a/test/support/fixtures/projects_fixtures.ex +++ b/test/support/fixtures/projects_fixtures.ex @@ -17,8 +17,10 @@ defmodule OrangeCms.ProjectsFixtures do type: :headless_cms }) + {:ok, params} = OrangeCms.Projects.CreateProjectParams.cast(attrs) + {:ok, project} = - OrangeCms.Projects.create_project(attrs, creator) + OrangeCms.Projects.create_project(params, creator) project end From 096a4089be2d50f8dd70d5a6b231d6982c2f8673 Mon Sep 17 00:00:00 2001 From: Dzung Nguyen Date: Sun, 25 Feb 2024 13:33:17 +0700 Subject: [PATCH 5/6] Add controller helper for access user and context --- lib/orange_cms_web/helpers/controller_helper.ex | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 lib/orange_cms_web/helpers/controller_helper.ex diff --git a/lib/orange_cms_web/helpers/controller_helper.ex b/lib/orange_cms_web/helpers/controller_helper.ex new file mode 100644 index 0000000..a84f5e9 --- /dev/null +++ b/lib/orange_cms_web/helpers/controller_helper.ex @@ -0,0 +1,13 @@ +defmodule OrangeCmsWeb.ControllerHelpers do + @moduledoc """ + This module contains helper functions for controllers + """ + + def current_user(socket_or_conn) do + socket_or_conn.assigns.current_user + end + + def context(socket_or_conn) do + socket_or_conn.assigns.context + end +end From f3aafdfdc56730f26dff5e9658c173364dda9ea5 Mon Sep 17 00:00:00 2001 From: Dzung Nguyen Date: Sun, 25 Feb 2024 13:33:44 +0700 Subject: [PATCH 6/6] Update testcases --- lib/orange_cms/projects.ex | 6 ++++-- .../projects/usecases/create_project_usecase.ex | 14 +++++++------- lib/orange_cms_web.ex | 3 +++ lib/orange_cms_web/components/lad_ui/input.ex | 2 ++ .../live/project_live/form_component.ex | 3 +-- lib/orange_cms_web/user_auth.ex | 9 ++++++++- test/orange_cms/projects_test.exs | 11 ++++------- test/support/fixtures/projects_fixtures.ex | 5 ++--- 8 files changed, 31 insertions(+), 22 deletions(-) diff --git a/lib/orange_cms/projects.ex b/lib/orange_cms/projects.ex index c13f0e8..1dd3f8d 100644 --- a/lib/orange_cms/projects.ex +++ b/lib/orange_cms/projects.ex @@ -2,6 +2,7 @@ defmodule OrangeCms.Projects do @moduledoc false use OrangeCms, :context + alias OrangeCms.Context alias OrangeCms.Projects.Project alias OrangeCms.Projects.ProjectMember @@ -46,8 +47,9 @@ defmodule OrangeCms.Projects do {:error, %Ecto.Changeset{}} """ - def create_project(attrs, creator) do - OrangeCms.Projects.CreateProjectUsecase.call(attrs, creator) + @spec create_project(map, Context.t()) :: {:ok, Project.t()} | {:error, Ecto.Changeset.t()} + def create_project(attrs, context) do + OrangeCms.Projects.CreateProjectUsecase.call(attrs, context) end @doc """ diff --git a/lib/orange_cms/projects/usecases/create_project_usecase.ex b/lib/orange_cms/projects/usecases/create_project_usecase.ex index 2abc946..99edb07 100644 --- a/lib/orange_cms/projects/usecases/create_project_usecase.ex +++ b/lib/orange_cms/projects/usecases/create_project_usecase.ex @@ -6,12 +6,12 @@ defmodule OrangeCms.Projects.CreateProjectUsecase do """ alias OrangeCms.Projects.CreateProjectParams - def call(%CreateProjectParams{} = params, actor) do - params - |> OrangeCms.Projects.CreateProjectCommand.call(actor) - |> handle_result() + def call(params, %{actor: actor} = _context) do + with {:ok, parsed_params} <- CreateProjectParams.cast(params), + {:ok, project} <- OrangeCms.Projects.CreateProjectCommand.call(parsed_params, actor) do + {:ok, project} + else + {:error, error} -> {:error, error} + end end - - defp handle_result({:error, changeset}), do: {:error, changeset} - defp handle_result({:ok, project}), do: {:ok, project} end diff --git a/lib/orange_cms_web.ex b/lib/orange_cms_web.ex index 3604a2f..575b38c 100644 --- a/lib/orange_cms_web.ex +++ b/lib/orange_cms_web.ex @@ -42,6 +42,7 @@ defmodule OrangeCmsWeb do formats: [:html, :json], layouts: [html: OrangeCmsWeb.Layouts] + import OrangeCmsWeb.ControllerHelpers import OrangeCmsWeb.Gettext import Plug.Conn @@ -62,6 +63,8 @@ defmodule OrangeCmsWeb do quote do use Phoenix.LiveComponent + import OrangeCmsWeb.ControllerHelpers + unquote(html_helpers()) end end diff --git a/lib/orange_cms_web/components/lad_ui/input.ex b/lib/orange_cms_web/components/lad_ui/input.ex index 0925da1..73216e8 100644 --- a/lib/orange_cms_web/components/lad_ui/input.ex +++ b/lib/orange_cms_web/components/lad_ui/input.ex @@ -7,6 +7,7 @@ defmodule OrangeCmsWeb.Components.LadUI.Input do attr(:id, :any, default: nil) attr(:name, :any) attr(:value, :any) + attr(:required, :boolean, default: false) attr(:type, :string, default: "text", @@ -33,6 +34,7 @@ defmodule OrangeCmsWeb.Components.LadUI.Input do id={@id} type={@type} name={@name} + required={@required} value={assigns[:value]} {@rest} /> diff --git a/lib/orange_cms_web/live/project_live/form_component.ex b/lib/orange_cms_web/live/project_live/form_component.ex index 31db4d3..9392ce7 100644 --- a/lib/orange_cms_web/live/project_live/form_component.ex +++ b/lib/orange_cms_web/live/project_live/form_component.ex @@ -55,8 +55,7 @@ defmodule OrangeCmsWeb.ProjectLive.FormComponent do end defp save_project(socket, :new, project_params) do - with {:ok, parsed_params} <- Projects.CreateProjectParams.cast(project_params), - {:ok, project} <- Projects.create_project(parsed_params, OrangeCms.get_actor()) do + with {:ok, project} <- Projects.create_project(project_params, context(socket)) do notify_parent({:saved, project}) {:noreply, diff --git a/lib/orange_cms_web/user_auth.ex b/lib/orange_cms_web/user_auth.ex index d33b8b4..b582772 100644 --- a/lib/orange_cms_web/user_auth.ex +++ b/lib/orange_cms_web/user_auth.ex @@ -92,7 +92,10 @@ defmodule OrangeCmsWeb.UserAuth do def fetch_current_user(conn, _opts) do {user_token, conn} = ensure_user_token(conn) user = user_token && Accounts.get_user_by_session_token(user_token) - assign(conn, :current_user, user) + + conn + |> assign(:current_user, user) + |> assign(:context, OrangeCms.Context.new(user: user)) end defp ensure_user_token(conn) do @@ -181,6 +184,10 @@ defmodule OrangeCmsWeb.UserAuth do Accounts.get_user_by_session_token(user_token) end end) + + Phoenix.Component.assign_new(socket, :context, fn -> + OrangeCms.Context.new(user: socket.assigns[:current_user]) + end) end @doc """ diff --git a/test/orange_cms/projects_test.exs b/test/orange_cms/projects_test.exs index 3b5b6eb..84e4b44 100644 --- a/test/orange_cms/projects_test.exs +++ b/test/orange_cms/projects_test.exs @@ -35,19 +35,16 @@ defmodule OrangeCms.ProjectsTest do type: :github } - {:ok, params} = OrangeCms.Projects.CreateProjectParams.cast(valid_attrs) - - assert {:ok, %Project{} = project} = Projects.create_project(params, user) + assert {:ok, %Project{} = project} = Projects.create_project(valid_attrs, OrangeCms.Context.new(actor: user)) assert project.name == "some name" assert project.type == :github end test "create_project/1 with invalid data returns error changeset" do - # user = random_user_fixture() - - assert {:error, _} = OrangeCms.Projects.CreateProjectParams.cast(@invalid_attrs) + user = random_user_fixture() - # assert {:error, %Ecto.Changeset{}} = Projects.create_project(@invalid_attrs, user) + assert {:error, %OrangeCms.ParamsError{}} = + Projects.create_project(@invalid_attrs, OrangeCms.Context.new(actor: user)) end test "update_project/2 with valid data updates the project" do diff --git a/test/support/fixtures/projects_fixtures.ex b/test/support/fixtures/projects_fixtures.ex index 5f7caea..6b52b31 100644 --- a/test/support/fixtures/projects_fixtures.ex +++ b/test/support/fixtures/projects_fixtures.ex @@ -3,6 +3,7 @@ defmodule OrangeCms.ProjectsFixtures do This module defines test helpers for creating entities via the `OrangeCms.Projects` context. """ + alias OrangeCms.Context @doc """ Generate a project. @@ -17,10 +18,8 @@ defmodule OrangeCms.ProjectsFixtures do type: :headless_cms }) - {:ok, params} = OrangeCms.Projects.CreateProjectParams.cast(attrs) - {:ok, project} = - OrangeCms.Projects.create_project(params, creator) + OrangeCms.Projects.create_project(attrs, Context.new(actor: creator)) project end