From 72afbc95583e18cfc0c28cbec87a8d5b6dfce668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lobo?= <30907944+joaodiaslobo@users.noreply.github.com> Date: Mon, 14 Oct 2024 18:49:51 +0100 Subject: [PATCH] feat: companies (#405) --- assets/js/app.js | 5 +- assets/js/hooks/index.js | 3 +- assets/js/hooks/sorting.js | 21 ++ assets/vendor/sortable.js | 2 + lib/safira/accounts/roles/permissions.ex | 1 + lib/safira/companies.ex | 253 ++++++++++++++++++ lib/safira/companies/company.ex | 52 ++++ lib/safira/companies/tier.ex | 26 ++ lib/safira/schema.ex | 9 + lib/safira/uploaders/company.ex | 28 ++ lib/safira_web/config.ex | 7 + .../companies_live/form_component.ex | 153 +++++++++++ .../live/backoffice/companies_live/index.ex | 79 ++++++ .../backoffice/companies_live/index.html.heex | 102 +++++++ .../tier_live/form_component.ex | 87 ++++++ .../companies_live/tier_live/index.ex | 85 ++++++ lib/safira_web/router.ex | 13 + lib/safira_web/staff_roles.ex | 31 ++- priv/fake/companies.txt | 29 ++ .../20241004172259_create_tiers.exs | 13 + .../20241004172300_create_companies.exs | 19 ++ priv/repo/seeds.exs | 3 +- priv/repo/seeds/companies.exs | 54 ++++ test/safira/companies_test.exs | 115 ++++++++ test/support/fixtures/companies_fixtures.ex | 36 +++ 25 files changed, 1210 insertions(+), 16 deletions(-) create mode 100644 assets/js/hooks/sorting.js create mode 100644 assets/vendor/sortable.js create mode 100644 lib/safira/companies.ex create mode 100644 lib/safira/companies/company.ex create mode 100644 lib/safira/companies/tier.ex create mode 100644 lib/safira/uploaders/company.ex create mode 100644 lib/safira_web/live/backoffice/companies_live/form_component.ex create mode 100644 lib/safira_web/live/backoffice/companies_live/index.ex create mode 100644 lib/safira_web/live/backoffice/companies_live/index.html.heex create mode 100644 lib/safira_web/live/backoffice/companies_live/tier_live/form_component.ex create mode 100644 lib/safira_web/live/backoffice/companies_live/tier_live/index.ex create mode 100644 priv/fake/companies.txt create mode 100644 priv/repo/migrations/20241004172259_create_tiers.exs create mode 100644 priv/repo/migrations/20241004172300_create_companies.exs create mode 100644 priv/repo/seeds/companies.exs create mode 100644 test/safira/companies_test.exs create mode 100644 test/support/fixtures/companies_fixtures.ex diff --git a/assets/js/app.js b/assets/js/app.js index 8e193f70..8209d75f 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -21,12 +21,13 @@ import "phoenix_html" import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" -import { QrScanner, Wheel, Confetti } from "./hooks"; +import { QrScanner, Wheel, Confetti, Sorting } from "./hooks"; let Hooks = { QrScanner: QrScanner, Wheel: Wheel, - Confetti: Confetti + Confetti: Confetti, + Sorting: Sorting }; let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js index c8ddc20a..b871780b 100644 --- a/assets/js/hooks/index.js +++ b/assets/js/hooks/index.js @@ -1,3 +1,4 @@ export { QrScanner } from "./qr_reading.js"; export { Wheel } from "./wheel.js"; -export { Confetti } from "./confetti.js"; \ No newline at end of file +export { Confetti } from "./confetti.js"; +export { Sorting } from "./sorting.js"; \ No newline at end of file diff --git a/assets/js/hooks/sorting.js b/assets/js/hooks/sorting.js new file mode 100644 index 00000000..0ede9a4b --- /dev/null +++ b/assets/js/hooks/sorting.js @@ -0,0 +1,21 @@ +import Sortable from "../../vendor/sortable"; + +export const Sorting = { + mounted() { + const placeholder = document.createElement('div'); + new Sortable(this.el, { + animation: 150, + ghostClass: "opacity-20", + dragClass: "shadow-2xl", + handle: ".handle", + onEnd: () => { + const elements = Array.from(this.el.children) + const ids = elements.map(elm => elm.id) + this.pushEventTo(this.el, "update-sorting", {ids: ids}) + }, + setData: (dataTransfer, _) => { + dataTransfer.setDragImage(placeholder, 0, 0); + } + }) + } +} \ No newline at end of file diff --git a/assets/vendor/sortable.js b/assets/vendor/sortable.js new file mode 100644 index 00000000..bb995335 --- /dev/null +++ b/assets/vendor/sortable.js @@ -0,0 +1,2 @@ +/*! Sortable 1.15.2 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;tt.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&p(t,e)||o&&t===n)return t}while(t!==n&&(t=(i=t).host&&i!==document&&i.host.nodeType?i.host:i.parentNode))}var i;return null}var g,m=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(m," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(m," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function v(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function b(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Bt(t){V&&V.parentNode[K]._isOutsideThisEl(t.target)}function Ft(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return Pt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==Ft.supportPointer&&"PointerEvent"in window&&!u,emptyInsertThreshold:5};for(n in W.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in kt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&Nt,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),Dt.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,x())}function jt(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Ht(t){t.draggable=!1}function Lt(){Tt=!1}function Kt(t){return setTimeout(t,0)}function Wt(t){return clearTimeout(t)}Ft.prototype={constructor:Ft,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(mt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,V):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){xt.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&xt.push(o)}}(o),!V&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||tt===l)){if(ot=j(l),rt=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return q({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),G("filter",n,{evt:e}),void(i&&e.cancelable&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return q({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),G("filter",n,{evt:e}),!0}))return void(i&&e.cancelable&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!V&&n.parentNode===r&&(o=X(n),Q=r,Z=(V=n).parentNode,J=V.nextSibling,tt=n,lt=a.group,ct={target:Ft.dragged=V,clientX:(e||t).clientX,clientY:(e||t).clientY},ft=ct.clientX-o.left,pt=ct.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,V.style["will-change"]="all",o=function(){G("delayEnded",i,{evt:t}),Ft.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(V.draggable=!0),i._triggerDragStart(t,e),q({sortable:i,name:"choose",originalEvent:t}),k(V,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){b(V,t.trim(),Ht)}),h(l,"dragover",Yt),h(l,"mousemove",Yt),h(l,"touchmove",Yt),h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,V.draggable=!0),G("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():Ft.eventCanceled?this._onDrop():(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){V&&Ht(V),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;f(t,"mouseup",this._disableDelayedDrag),f(t,"touchend",this._disableDelayedDrag),f(t,"touchcancel",this._disableDelayedDrag),f(t,"mousemove",this._delayedDragTouchMoveHandler),f(t,"touchmove",this._delayedDragTouchMoveHandler),f(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(V,"dragend",this),h(Q,"dragstart",this._onDragStart));try{document.selection?Kt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;wt=!1,Q&&V?(G("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Bt),n=this.options,t||k(V,n.dragClass,!1),k(V,n.ghostClass,!0),Ft.active=this,t&&this._appendGhost(),q({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(ut){this._lastX=ut.clientX,this._lastY=ut.clientY,Rt();for(var t=document.elementFromPoint(ut.clientX,ut.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(ut.clientX,ut.clientY))!==e;)e=t;if(V.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:ut.clientX,clientY:ut.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=(t=e).parentNode);Xt()}},_onTouchMove:function(t){if(ct){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=$&&v($,!0),a=$&&r&&r.a,l=$&&r&&r.d,e=Mt&&yt&&E(yt),a=(i.clientX-ct.clientX+o.x)/(a||1)+(e?e[0]-Ct[0]:0)/(a||1),l=(i.clientY-ct.clientY+o.y)/(l||1)+(e?e[1]-Ct[1]:0)/(l||1);if(!Ft.active&&!wt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))D.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>D.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,$),e?t.clientX<_.left-10||t.clientY ["show", "edit"], "staffs" => ["show", "edit", "roles_edit"], + "companies" => ["edit"], "products" => ["show", "edit", "delete"], "purchases" => ["show", "redeem", "refund"], "badges" => ["show", "edit", "delete", "give", "revoke", "give_without_restrictions"], diff --git a/lib/safira/companies.ex b/lib/safira/companies.ex new file mode 100644 index 00000000..071ebd3a --- /dev/null +++ b/lib/safira/companies.ex @@ -0,0 +1,253 @@ +defmodule Safira.Companies do + @moduledoc """ + The Companies context. + """ + + use Safira.Context + + alias Safira.Companies.Company + + @doc """ + Returns the list of companies. + + ## Examples + + iex> list_companies() + [%Company{}, ...] + + """ + def list_companies do + Company + |> Repo.all() + end + + def list_companies(opts) when is_list(opts) do + Company + |> apply_filters(opts) + |> Repo.all() + end + + def list_companies(params) do + Company + |> join(:left, [c], t in assoc(c, :tier), as: :tier) + |> preload(:tier) + |> Flop.validate_and_run(params, for: Company) + end + + def list_companies(%{} = params, opts) when is_list(opts) do + Company + |> apply_filters(opts) + |> join(:left, [c], t in assoc(c, :tier), as: :tier) + |> preload(:tier) + |> Flop.validate_and_run(params, for: Company) + end + + @doc """ + Gets a single company. + + Raises `Ecto.NoResultsError` if the Company does not exist. + + ## Examples + + iex> get_company!(123) + %Company{} + + iex> get_company!(456) + ** (Ecto.NoResultsError) + + """ + def get_company!(id), do: Repo.get!(Company, id) + + @doc """ + Creates a company. + + ## Examples + + iex> create_company(%{field: value}) + {:ok, %Company{}} + + iex> create_company(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_company(attrs \\ %{}) do + %Company{} + |> Company.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a company. + + ## Examples + + iex> update_company(company, %{field: new_value}) + {:ok, %Company{}} + + iex> update_company(company, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_company(%Company{} = company, attrs) do + company + |> Company.changeset(attrs) + |> Repo.update() + end + + @doc """ + Updates a company logo. + + ## Examples + + iex> update_company_logo(company, %{logo: image}) + {:ok, %Company{}} + + iex> update_company_logo(company, %{logo: bad_image}) + {:error, %Ecto.Changeset{}} + + """ + def update_company_logo(%Company{} = company, attrs) do + company + |> Company.image_changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a company. + + ## Examples + + iex> delete_company(company) + {:ok, %Company{}} + + iex> delete_company(company) + {:error, %Ecto.Changeset{}} + + """ + def delete_company(%Company{} = company) do + Repo.delete(company) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking company changes. + + ## Examples + + iex> change_company(company) + %Ecto.Changeset{data: %Company{}} + + """ + def change_company(%Company{} = company, attrs \\ %{}) do + Company.changeset(company, attrs) + end + + alias Safira.Companies.Tier + + @doc """ + Returns the list of tiers. + + ## Examples + + iex> list_tiers() + [%Tier{}, ...] + + """ + def list_tiers do + Tier + |> order_by(:priority) + |> Repo.all() + end + + @doc """ + Gets a single tier. + + Raises `Ecto.NoResultsError` if the Tier does not exist. + + ## Examples + + iex> get_tier!(123) + %Tier{} + + iex> get_tier!(456) + ** (Ecto.NoResultsError) + + """ + def get_tier!(id), do: Repo.get!(Tier, id) + + @doc """ + Creates a tier. + + ## Examples + + iex> create_tier(%{field: value}) + {:ok, %Tier{}} + + iex> create_tier(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_tier(attrs \\ %{}) do + %Tier{} + |> Tier.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a tier. + + ## Examples + + iex> update_tier(tier, %{field: new_value}) + {:ok, %Tier{}} + + iex> update_tier(tier, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_tier(%Tier{} = tier, attrs) do + tier + |> Tier.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a tier. + + ## Examples + + iex> delete_tier(tier) + {:ok, %Tier{}} + + iex> delete_tier(tier) + {:error, %Ecto.Changeset{}} + + """ + def delete_tier(%Tier{} = tier) do + Repo.delete(tier) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking tier changes. + + ## Examples + + iex> change_tier(tier) + %Ecto.Changeset{data: %Tier{}} + + """ + def change_tier(%Tier{} = tier, attrs \\ %{}) do + Tier.changeset(tier, attrs) + end + + @doc """ + Returns the next priority a tier should have. + + ## Examples + + iex> get_next_tier_priority() + 5 + """ + def get_next_tier_priority do + (Repo.aggregate(from(t in Tier), :max, :priority) || -1) + 1 + end +end diff --git a/lib/safira/companies/company.ex b/lib/safira/companies/company.ex new file mode 100644 index 00000000..d1e57d4d --- /dev/null +++ b/lib/safira/companies/company.ex @@ -0,0 +1,52 @@ +defmodule Safira.Companies.Company do + @moduledoc """ + Companies present at the event. + """ + use Safira.Schema + + @derive { + Flop.Schema, + filterable: [:name], + sortable: [:name, :tier], + default_limit: 11, + join_fields: [ + tier: [ + binding: :tier, + field: :priority, + path: [:tier, :priority], + ecto_type: :integer + ] + ] + } + + @required_fields ~w(name tier_id)a + @optional_fields ~w(badge_id url)a + + schema "companies" do + field :name, :string + field :url, :string + field :logo, Uploaders.Company.Type + + belongs_to :badge, Safira.Contest.Badge + belongs_to :tier, Safira.Companies.Tier + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(company, attrs) do + company + |> cast(attrs, @required_fields ++ @optional_fields) + |> unique_constraint(:badge_id) + |> cast_assoc(:badge) + |> cast_assoc(:tier) + |> validate_required(@required_fields) + |> validate_url(:url) + end + + @doc false + def image_changeset(badge, attrs) do + badge + |> cast_attachments(attrs, [:logo]) + end +end diff --git a/lib/safira/companies/tier.ex b/lib/safira/companies/tier.ex new file mode 100644 index 00000000..eedc7194 --- /dev/null +++ b/lib/safira/companies/tier.ex @@ -0,0 +1,26 @@ +defmodule Safira.Companies.Tier do + @moduledoc """ + Sponsor tiers for companies. + """ + use Safira.Schema + + @required_fields ~w(name priority)a + + @derive {Flop.Schema, sortable: [:priority], filterable: []} + + schema "tiers" do + field :name, :string + field :priority, :integer + + has_many :companies, Safira.Companies.Company, foreign_key: :tier_id + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(tier, attrs) do + tier + |> cast(attrs, @required_fields) + |> validate_required(@required_fields) + end +end diff --git a/lib/safira/schema.ex b/lib/safira/schema.ex index 8ef5f69e..d3b81373 100644 --- a/lib/safira/schema.ex +++ b/lib/safira/schema.ex @@ -13,6 +13,15 @@ defmodule Safira.Schema do @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id + + def validate_url(changeset, field) do + changeset + |> validate_format( + :url, + ~r/^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$/, + message: "must start with http:// or https:// and have a valid domain" + ) + end end end end diff --git a/lib/safira/uploaders/company.ex b/lib/safira/uploaders/company.ex new file mode 100644 index 00000000..a9d6cafd --- /dev/null +++ b/lib/safira/uploaders/company.ex @@ -0,0 +1,28 @@ +defmodule Safira.Uploaders.Company do + @moduledoc """ + Company image uploader. + """ + use Safira.Uploader + + alias Safira.Companies.Company + + @versions [:original] + @extension_whitelist ~w(.svg .png) + + def validate({file, _}) do + file_extension = file.file_name |> Path.extname() |> String.downcase() + Enum.member?(extension_whitelist(), file_extension) + end + + def storage_dir(_, {_file, %Company{} = company}) do + "uploads/companies/company/#{company.id}" + end + + def filename(version, _) do + version + end + + def extension_whitelist do + @extension_whitelist + end +end diff --git a/lib/safira_web/config.ex b/lib/safira_web/config.ex index 9059d687..c9fab043 100644 --- a/lib/safira_web/config.ex +++ b/lib/safira_web/config.ex @@ -62,6 +62,13 @@ defmodule SafiraWeb.Config do url: "/dashboard/staffs", scope: %{"staffs" => ["show"]} }, + %{ + key: :companies, + title: "Companies", + icon: "hero-building-office", + url: "/dashboard/companies", + scope: %{"companies" => ["edit"]} + }, %{ key: :store, title: "Store", diff --git a/lib/safira_web/live/backoffice/companies_live/form_component.ex b/lib/safira_web/live/backoffice/companies_live/form_component.ex new file mode 100644 index 00000000..a6a8fb7d --- /dev/null +++ b/lib/safira_web/live/backoffice/companies_live/form_component.ex @@ -0,0 +1,153 @@ +defmodule SafiraWeb.Backoffice.CompanyLive.FormComponent do + use SafiraWeb, :live_component + + alias Safira.Companies + alias Safira.Uploaders.Company + + import SafiraWeb.Components.ImageUploader + import SafiraWeb.Components.Forms + + @impl true + def render(assigns) do + ~H""" +
+ <.header> + <%= @title %> + <:subtitle> + <%= gettext("Companies sponsor the event.") %> + + + + <.simple_form + for={@form} + id="company-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > +
+
+ <.field field={@form[:name]} type="text" label="Name" wrapper_class="pr-2" required /> + <.field field={@form[:url]} type="text" label="URL" wrapper_class="" /> + <.field + field={@form[:tier_id]} + type="select" + options={options(@tiers)} + label="Tier" + wrapper_class="pr-2" + required + /> + <.field + field={@form[:badge_id]} + type="select" + options={options(@badges)} + label="Badge" + wrapper_class="" + required + /> +
+
+ <.field_label>Logo + <.image_uploader + class="w-full h-80" + image_class="h-80" + icon="hero-building-office" + upload={@uploads.logo} + image={Uploaders.Company.url({@company.logo, @company}, :original)} + /> +
+
+ <:actions> + <.button phx-disable-with="Saving...">Save Company + + +
+ """ + end + + @impl true + def mount(socket) do + {:ok, + socket + |> assign(:uploaded_files, []) + |> allow_upload(:logo, + accept: Company.extension_whitelist(), + max_entries: 1 + )} + end + + @impl true + def update(%{company: company} = assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign_new(:form, fn -> + to_form(Companies.change_company(company)) + end)} + end + + @impl true + def handle_event("validate", %{"company" => company_params}, socket) do + changeset = Companies.change_company(socket.assigns.company, company_params) + {:noreply, assign(socket, form: to_form(changeset, action: :validate))} + end + + def handle_event("save", %{"company" => company_params}, socket) do + save_company(socket, socket.assigns.action, company_params) + end + + defp save_company(socket, :edit, company_params) do + case Companies.update_company(socket.assigns.company, company_params) do + {:ok, company} -> + case consume_image_data(company, socket) do + {:ok, _company} -> + {:noreply, + socket + |> put_flash(:info, "Company updated successfully") + |> push_patch(to: socket.assigns.patch)} + end + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp save_company(socket, :new, company_params) do + case Companies.create_company(company_params) do + {:ok, company} -> + case consume_image_data(company, socket) do + {:ok, _company} -> + {:noreply, + socket + |> put_flash(:info, "Company created successfully") + |> push_patch(to: socket.assigns.patch)} + end + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp consume_image_data(company, socket) do + consume_uploaded_entries(socket, :logo, fn %{path: path}, entry -> + Companies.update_company_logo(company, %{ + "logo" => %Plug.Upload{ + content_type: entry.client_type, + filename: entry.client_name, + path: path + } + }) + end) + |> case do + [{:ok, company}] -> + {:ok, company} + + _errors -> + {:ok, company} + end + end + + defp options(tiers) do + Enum.map(tiers, &{&1.name, &1.id}) + end +end diff --git a/lib/safira_web/live/backoffice/companies_live/index.ex b/lib/safira_web/live/backoffice/companies_live/index.ex new file mode 100644 index 00000000..c5814843 --- /dev/null +++ b/lib/safira_web/live/backoffice/companies_live/index.ex @@ -0,0 +1,79 @@ +defmodule SafiraWeb.Backoffice.CompanyLive.Index do + use SafiraWeb, :backoffice_view + + import SafiraWeb.Components.{Table, TableSearch} + + alias Safira.{Companies, Contest} + alias Safira.Companies.{Company, Tier} + + on_mount {SafiraWeb.StaffRoles, index: %{"companies" => ["edit"]}} + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(params, _url, socket) do + case Companies.list_companies(params) do + {:ok, {companies, meta}} -> + {:noreply, + socket + |> assign(:current_page, :companies) + |> assign(:meta, meta) + |> assign(:params, params) + |> stream(:companies, companies, reset: true) + |> apply_action(socket.assigns.live_action, params)} + + {:error, _} -> + {:noreply, socket} + end + end + + defp apply_action(socket, :edit, %{"id" => id}) do + socket + |> assign(:page_title, "Edit Company") + |> assign(:company, Companies.get_company!(id)) + |> assign(:tiers, Companies.list_tiers()) + |> assign(:badges, Contest.list_badges()) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "New Company") + |> assign(:company, %Company{}) + |> assign(:tiers, Companies.list_tiers()) + |> assign(:badges, Contest.list_badges()) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing Companies") + end + + defp apply_action(socket, :tiers_edit, %{"id" => id}) do + socket + |> assign(:page_title, "Edit Sponsor Tier") + |> assign(:tier, Companies.get_tier!(id)) + end + + defp apply_action(socket, :tiers_new, _params) do + socket + |> assign(:page_title, "New Sponsor Tier") + |> assign(:tier, %Tier{}) + end + + defp apply_action(socket, :tiers, _params) do + socket + |> assign(:page_title, "Listing Sponsor Tiers") + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + company = Companies.get_company!(id) + + {:ok, _} = Companies.delete_company(company) + + {:noreply, stream_delete(socket, :companies, company)} + end +end diff --git a/lib/safira_web/live/backoffice/companies_live/index.html.heex b/lib/safira_web/live/backoffice/companies_live/index.html.heex new file mode 100644 index 00000000..8a5d3d13 --- /dev/null +++ b/lib/safira_web/live/backoffice/companies_live/index.html.heex @@ -0,0 +1,102 @@ +<.page title="Companies"> + <:actions> +
+ <.table_search + id="company-table-name-search" + params={@params} + field={:name} + path={~p"/dashboard/companies"} + placeholder={gettext("Search for companies")} + /> + <.ensure_permissions user={@current_user} permissions={%{"companies" => ["edit"]}}> + <.link patch={~p"/dashboard/companies/new"}> + <.button>New Company + + + + <.ensure_permissions user={@current_user} permissions={%{"companies" => ["edit"]}}> + <.link patch={~p"/dashboard/companies/tiers"}> + <.button> + <.icon name="hero-rectangle-stack" class="w-5 h-5" /> + + + +
+ + +
+ <.table id="companies-table" items={@streams.companies} meta={@meta} params={@params}> + <:col :let={{_id, company}} sortable field={:name} label="Name"><%= company.name %> + <:col :let={{_id, company}} sortable field={:tier} label="Tier"> + <%= company.tier.name %> + + <:action :let={{id, company}}> + <.ensure_permissions user={@current_user} permissions={%{"companies" => ["edit"]}}> +
+ <.link patch={~p"/dashboard/companies/#{company.id}/edit"}> + <.icon name="hero-pencil" class="w-5 h-5" /> + + <.link + phx-click={JS.push("delete", value: %{id: company.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + <.icon name="hero-trash" class="w-5 h-5" /> + +
+ + + +
+ + +<.modal + :if={@live_action in [:new, :edit]} + id="company-modal" + show + on_cancel={JS.patch(~p"/dashboard/companies")} +> + <.live_component + module={SafiraWeb.Backoffice.CompanyLive.FormComponent} + id={@company.id || :new} + title={@page_title} + current_user={@current_user} + action={@live_action} + company={@company} + tiers={@tiers} + badges={@badges} + patch={~p"/dashboard/companies"} + /> + + +<.modal + :if={@live_action in [:tiers]} + id="tiers-modal" + show + on_cancel={JS.patch(~p"/dashboard/companies")} +> + <.live_component + module={SafiraWeb.Backoffice.CompanyLive.TierLive.Index} + id="list-tiers" + title={@page_title} + current_user={@current_user} + action={@live_action} + patch={~p"/dashboard/companies"} + /> + + +<.modal + :if={@live_action in [:tiers_edit, :tiers_new]} + id="tiers-modal" + show + on_cancel={JS.navigate(~p"/dashboard/companies/tiers")} +> + <.live_component + module={SafiraWeb.Backoffice.CompanyLive.TierLive.FormComponent} + id={@tier.id || :new} + title={@page_title} + current_user={@current_user} + action={@live_action} + tier={@tier} + patch={~p"/dashboard/companies/tiers"} + /> + diff --git a/lib/safira_web/live/backoffice/companies_live/tier_live/form_component.ex b/lib/safira_web/live/backoffice/companies_live/tier_live/form_component.ex new file mode 100644 index 00000000..f2a2a71a --- /dev/null +++ b/lib/safira_web/live/backoffice/companies_live/tier_live/form_component.ex @@ -0,0 +1,87 @@ +defmodule SafiraWeb.Backoffice.CompanyLive.TierLive.FormComponent do + use SafiraWeb, :live_component + + alias Safira.Companies + import SafiraWeb.Components.Forms + + @impl true + def render(assigns) do + ~H""" +
+ <.page + title={@title} + subtitle={gettext("Every company sponsoring the event gets assigned a tier.")} + > + <.simple_form + for={@form} + id="tier-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > +
+ <.field field={@form[:name]} type="text" label="Name" required /> +
+ <:actions> + <.button phx-disable-with="Saving...">Save Tier + + + +
+ """ + end + + @impl true + def mount(socket) do + {:ok, socket} + end + + @impl true + def update(%{tier: tier} = assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign_new(:form, fn -> + to_form(Companies.change_tier(tier)) + end)} + end + + @impl true + def handle_event("validate", %{"tier" => tier_params}, socket) do + changeset = Companies.change_tier(socket.assigns.tier, tier_params) + {:noreply, assign(socket, form: to_form(changeset, action: :validate))} + end + + def handle_event("save", %{"tier" => tier_params}, socket) do + save_tier(socket, socket.assigns.action, tier_params) + end + + defp save_tier(socket, :tiers_edit, tier_params) do + case Companies.update_tier(socket.assigns.tier, tier_params) do + {:ok, _tier} -> + {:noreply, + socket + |> put_flash(:info, "Company tier updated successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp save_tier(socket, :tiers_new, tier_params) do + case Companies.create_tier( + tier_params + |> Map.put("priority", Companies.get_next_tier_priority()) + ) do + {:ok, _tier} -> + {:noreply, + socket + |> put_flash(:info, "Company tier created successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end +end diff --git a/lib/safira_web/live/backoffice/companies_live/tier_live/index.ex b/lib/safira_web/live/backoffice/companies_live/tier_live/index.ex new file mode 100644 index 00000000..64d3df48 --- /dev/null +++ b/lib/safira_web/live/backoffice/companies_live/tier_live/index.ex @@ -0,0 +1,85 @@ +defmodule SafiraWeb.Backoffice.CompanyLive.TierLive.Index do + use SafiraWeb, :live_component + + alias Safira.Companies + import SafiraWeb.Components.EnsurePermissions + + @impl true + def render(assigns) do + ~H""" +
+ <.page title={@title}> + <:actions> + <.ensure_permissions user={@current_user} permissions={%{"companies" => ["edit"]}}> + <.link navigate={~p"/dashboard/companies/tiers/new"}> + <.button>New Tier + + + +
    +
  • tier.id} + class="even:bg-lightShade/20 dark:even:bg-darkShade/20 py-4 px-4 flex flex-row justify-between" + > +
    + <.icon name="hero-bars-3" class="w-5 h-5 handle cursor-pointer ml-4" /> + <%= tier.name %> +
    +

    + <.ensure_permissions user={@current_user} permissions={%{"companies" => ["edit"]}}> + <.link navigate={~p"/dashboard/companies/tiers/#{tier.id}/edit"}> + <.icon name="hero-pencil" class="w-5 h-4" /> + + <.link + phx-click={ + JS.push("delete", value: %{id: tier.id}) |> hide("##{"tier-" <> tier.id}") + } + data-confirm="Are you sure?" + phx-target={@myself} + > + <.icon name="hero-trash" class="w-5 h-5" /> + + +

    +
  • +
+ +
+ """ + end + + @impl true + def mount(socket) do + {:ok, + socket + |> stream(:tiers, Companies.list_tiers())} + end + + @impl true + def handle_event("update-sorting", %{"ids" => ids}, socket) do + ids + |> Enum.with_index(0) + |> Enum.each(fn {"tier-" <> id, index} -> + id + |> Companies.get_tier!() + |> Companies.update_tier(%{priority: index}) + end) + + {:noreply, socket} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + tier = Companies.get_tier!(id) + + {:ok, _} = Companies.delete_tier(tier) + + {:noreply, stream_delete(socket, :tiers, tier)} + end +end diff --git a/lib/safira_web/router.ex b/lib/safira_web/router.ex index ed5eb8e0..cf3a31ce 100644 --- a/lib/safira_web/router.ex +++ b/lib/safira_web/router.ex @@ -111,6 +111,19 @@ defmodule SafiraWeb.Router do end end + scope "/companies", CompanyLive do + live "/", Index, :index + live "/new", Index, :new + + live "/:id/edit", Index, :edit + + scope "/tiers" do + live "/", Index, :tiers + live "/new", Index, :tiers_new + live "/:id/edit", Index, :tiers_edit + end + end + scope "/store/products", ProductLive do live "/", Index, :index live "/new", Index, :new diff --git a/lib/safira_web/staff_roles.ex b/lib/safira_web/staff_roles.ex index 1e7d7603..4223ee7c 100644 --- a/lib/safira_web/staff_roles.ex +++ b/lib/safira_web/staff_roles.ex @@ -7,20 +7,27 @@ defmodule SafiraWeb.StaffRoles do permissions = user.staff.role.permissions |> Enum.into(%{}) - scopes = Keyword.get(scope, live_action) - scope_key = Map.keys(scopes) |> List.first() - scope_value = Map.get(scopes, scope_key) - values = Map.get(permissions, scope_key, []) + case Keyword.get(scope, live_action) do + nil -> + # No permissions required + {:cont, socket} - match = Enum.all?(scope_value, fn x -> Enum.member?(values, x) end) + scopes -> + # Verify permissions + scope_key = Map.keys(scopes) |> List.first() + scope_value = Map.get(scopes, scope_key) + values = Map.get(permissions, scope_key, []) - if match do - {:cont, socket} - else - {:halt, - socket - |> Phoenix.LiveView.put_flash(:error, "You are not authorized to access this page.") - |> Phoenix.LiveView.redirect(to: "/dashboard")} + match = Enum.all?(scope_value, fn x -> Enum.member?(values, x) end) + + if match do + {:cont, socket} + else + {:halt, + socket + |> Phoenix.LiveView.put_flash(:error, "You are not authorized to access this page.") + |> Phoenix.LiveView.redirect(to: "/dashboard")} + end end end end diff --git a/priv/fake/companies.txt b/priv/fake/companies.txt new file mode 100644 index 00000000..e753184b --- /dev/null +++ b/priv/fake/companies.txt @@ -0,0 +1,29 @@ +Accenture;https://accenture.com/ +AgentifAI;https://agentifai.com/ +Checkmarx;https://checkmarx.com/ +Critical Techworks;https://www.criticaltechworks.com/ +Retail Consult;https://www.retail-consult.com/ +Uphold;https://uphold.com/ +Cegid;https://www.cegid.com/ +Codsec;https://www.codsec.io/ +Continental;https://www.continental.com +Deloitte;https://www2.deloitte.com/ +ITSector;https://www.itsector.pt/ +MCSonae;https://mc.sonae.pt/ +Optare Solutions;https://optaresolutions.com/ +XpandIT;https://www.xpand-it.com/ +Yari Labs;https://www.yarilabs.com/ +Aptiv;https://www.aptiv.com/ +Bosch;https://www.bosch.pt/ +Celfocus;https://www.celfocus.com/ +Dtxcolab;https://www.dtx-colab.pt/ +Eurotux;https://eurotux.com +EY;https://www.ey.com/ +Glintt;https://www.glintt.com +Inovaria;https://www.inova-ria.pt/ +KPMG;https://kpmg.com +Issuu;https://issuu.com/ +NTT Data;https://www.nttdata.com/ +Openvia;https://www.openvia.io/ +Pixelmatters;https://www.pixelmatters.com/ +Seegno;https://seegno.com/ \ No newline at end of file diff --git a/priv/repo/migrations/20241004172259_create_tiers.exs b/priv/repo/migrations/20241004172259_create_tiers.exs new file mode 100644 index 00000000..daf9c873 --- /dev/null +++ b/priv/repo/migrations/20241004172259_create_tiers.exs @@ -0,0 +1,13 @@ +defmodule Safira.Repo.Migrations.CreateTiers do + use Ecto.Migration + + def change do + create table(:tiers, primary_key: false) do + add :id, :binary_id, primary_key: true + add :name, :string, null: false + add :priority, :integer, null: false + + timestamps(type: :utc_datetime) + end + end +end diff --git a/priv/repo/migrations/20241004172300_create_companies.exs b/priv/repo/migrations/20241004172300_create_companies.exs new file mode 100644 index 00000000..fe1b4c04 --- /dev/null +++ b/priv/repo/migrations/20241004172300_create_companies.exs @@ -0,0 +1,19 @@ +defmodule Safira.Repo.Migrations.CreateCompanies do + use Ecto.Migration + + def change do + create table(:companies, primary_key: false) do + add :id, :binary_id, primary_key: true + add :name, :string + add :url, :string + add :logo, :string + + add :badge_id, references(:badges, type: :binary_id, on_delete: :delete_all) + add :tier_id, references(:tiers, type: :binary_id, on_delete: :restrict), null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:companies, [:badge_id]) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 57fe69ea..02712b4b 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -14,7 +14,8 @@ defmodule Safira.Repo.Seeds do "badges.exs", "store.exs", "vault.exs", - "prizes.exs" + "prizes.exs", + "companies.exs" ] |> Enum.each(fn file -> Code.require_file("#{@seeds_dir}/#{file}") diff --git a/priv/repo/seeds/companies.exs b/priv/repo/seeds/companies.exs new file mode 100644 index 00000000..a4bd84f7 --- /dev/null +++ b/priv/repo/seeds/companies.exs @@ -0,0 +1,54 @@ +defmodule Safira.Repo.Seeds.Companies do + alias Safira.{Companies, Repo} + alias Safira.Companies.{Company, Tier} + + @companies File.read!("priv/fake/companies.txt") |> String.split("\n") |> Enum.map(&String.split(&1, ";")) + + def run do + case Companies.list_companies() do + [] -> + seed_companies() + _ -> + Mix.shell().error("Found companies, aborting seeding companies.") + end + end + + def seed_companies do + tiers = + [ + %Tier{ + name: "Gold", + priority: 0 + }, + %Tier{ + name: "Silver", + priority: 1 + }, + %Tier{ + name: "Bronze", + priority: 2 + } + ] |> Enum.map(&Repo.insert(&1)) + + for company <- @companies do + {name, url, tier} = {Enum.at(company, 0), Enum.at(company, 1), Enum.random(tiers) |> elem(1)} + + company_seed = %{ + name: name, + url: url, + tier_id: tier.id + } + + changeset = Companies.change_company(%Company{}, company_seed) + + case Repo.insert(changeset) do + {:ok, _} -> :ok + {:error, changeset} -> + Mix.shell().error("Failed to insert company: #{company_seed.name}") + Mix.shell().error(Kernel.inspect(changeset.errors)) + end + end + end +end + +Safira.Repo.Seeds.Companies.run() diff --git a/test/safira/companies_test.exs b/test/safira/companies_test.exs new file mode 100644 index 00000000..31038403 --- /dev/null +++ b/test/safira/companies_test.exs @@ -0,0 +1,115 @@ +defmodule Safira.CompaniesTest do + use Safira.DataCase + + alias Safira.Companies + + describe "companies" do + alias Safira.Companies.Company + + import Safira.CompaniesFixtures + + @invalid_attrs %{name: nil} + + test "list_companies/0 returns all companies" do + company = company_fixture() + assert Companies.list_companies() == [company] + end + + test "get_company!/1 returns the company with given id" do + company = company_fixture() + assert Companies.get_company!(company.id) == company + end + + test "create_company/1 with valid data creates a company" do + valid_attrs = %{name: "some name", tier_id: tier_fixture().id} + + assert {:ok, %Company{} = company} = Companies.create_company(valid_attrs) + assert company.name == "some name" + end + + test "create_company/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Companies.create_company(@invalid_attrs) + end + + test "update_company/2 with valid data updates the company" do + company = company_fixture() + update_attrs = %{name: "some updated name"} + + assert {:ok, %Company{} = company} = Companies.update_company(company, update_attrs) + assert company.name == "some updated name" + end + + test "update_company/2 with invalid data returns error changeset" do + company = company_fixture() + assert {:error, %Ecto.Changeset{}} = Companies.update_company(company, @invalid_attrs) + assert company == Companies.get_company!(company.id) + end + + test "delete_company/1 deletes the company" do + company = company_fixture() + assert {:ok, %Company{}} = Companies.delete_company(company) + assert_raise Ecto.NoResultsError, fn -> Companies.get_company!(company.id) end + end + + test "change_company/1 returns a company changeset" do + company = company_fixture() + assert %Ecto.Changeset{} = Companies.change_company(company) + end + end + + describe "tiers" do + alias Safira.Companies.Tier + + import Safira.CompaniesFixtures + + @invalid_attrs %{name: nil, priority: nil} + + test "list_tiers/0 returns all tiers" do + tier = tier_fixture() + assert Companies.list_tiers() == [tier] + end + + test "get_tier!/1 returns the tier with given id" do + tier = tier_fixture() + assert Companies.get_tier!(tier.id) == tier + end + + test "create_tier/1 with valid data creates a tier" do + valid_attrs = %{name: "some name", priority: 42} + + assert {:ok, %Tier{} = tier} = Companies.create_tier(valid_attrs) + assert tier.name == "some name" + assert tier.priority == 42 + end + + test "create_tier/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Companies.create_tier(@invalid_attrs) + end + + test "update_tier/2 with valid data updates the tier" do + tier = tier_fixture() + update_attrs = %{name: "some updated name", priority: 43} + + assert {:ok, %Tier{} = tier} = Companies.update_tier(tier, update_attrs) + assert tier.name == "some updated name" + assert tier.priority == 43 + end + + test "update_tier/2 with invalid data returns error changeset" do + tier = tier_fixture() + assert {:error, %Ecto.Changeset{}} = Companies.update_tier(tier, @invalid_attrs) + assert tier == Companies.get_tier!(tier.id) + end + + test "delete_tier/1 deletes the tier" do + tier = tier_fixture() + assert {:ok, %Tier{}} = Companies.delete_tier(tier) + assert_raise Ecto.NoResultsError, fn -> Companies.get_tier!(tier.id) end + end + + test "change_tier/1 returns a tier changeset" do + tier = tier_fixture() + assert %Ecto.Changeset{} = Companies.change_tier(tier) + end + end +end diff --git a/test/support/fixtures/companies_fixtures.ex b/test/support/fixtures/companies_fixtures.ex new file mode 100644 index 00000000..4a6debea --- /dev/null +++ b/test/support/fixtures/companies_fixtures.ex @@ -0,0 +1,36 @@ +defmodule Safira.CompaniesFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Safira.Companies` context. + """ + + @doc """ + Generate a company. + """ + def company_fixture(attrs \\ %{}) do + {:ok, company} = + attrs + |> Enum.into(%{ + name: "some name", + tier_id: tier_fixture().id + }) + |> Safira.Companies.create_company() + + company + end + + @doc """ + Generate a tier. + """ + def tier_fixture(attrs \\ %{}) do + {:ok, tier} = + attrs + |> Enum.into(%{ + name: "some name", + priority: 42 + }) + |> Safira.Companies.create_tier() + + tier + end +end