diff --git a/platform/assets/css/app.css b/platform/assets/css/app.css index 17bb71ce6..3feb2de99 100644 --- a/platform/assets/css/app.css +++ b/platform/assets/css/app.css @@ -298,6 +298,16 @@ } } +/* Hide if there's a preceding sibling with data-sortable-id */ +[data-sortable-id] ~ .sibling-sortable-hidden { + display: none; +} + +/* Hide if there's a following sibling with data-sortable-id */ +.sibling-sortable-hidden:has(~ [data-sortable-id]) { + display: none; +} + /** Important CSS for our custom full-page map styling., */ diff --git a/platform/assets/js/app.js b/platform/assets/js/app.js index a1651578d..fae572d26 100644 --- a/platform/assets/js/app.js +++ b/platform/assets/js/app.js @@ -17,6 +17,7 @@ // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. import "phoenix_html" +import Sortable from 'sortablejs'; import * as vega from "vega"; import "vega-lite" import vegaEmbed from "vega-embed" @@ -66,6 +67,34 @@ Hooks.ScrollToTop = { } } Hooks.InfiniteScroll = InfiniteScroll; +Hooks.Sortable = { + mounted() { + // To use the sorter, create an element with `data-list_id` and + // `data-list_group`; then add a `data-sortable-id` to each of the things + // that you want to be draggable. + let sorter = new Sortable(this.el, { + animation: 150, + delay: 100, + handle: ".handle", + sort: this.el.getAttribute("data-sortable") !== "false", + dragClass: "drag-item", + ghostClass: "drag-ghost", + group: this.el.getAttribute("data-list_group"), + forceFallback: true, + onEnd: e => { + // Determine the new ordering of all elements in the group + let groupId = e.item.parentElement.dataset["list_id"]; + let newOrderingElements = e.item.parentElement.querySelectorAll(":scope > [data-sortable-id]") + let newOrdering = Array.from(newOrderingElements).map((elem) => elem.dataset.sortableId); + let params = { old: e.oldIndex, new: e.newIndex, id: e.item.dataset["sortableId"], ordering: newOrdering, group: groupId } + this.pushEventTo(this.el, "reposition", params) + }, + onMove(e) { + return !e.related.hasAttribute("data-sortable-fixed") + } + }) + } +} // Used by the pagination button to scroll back up to the top of the page. window.scrollToTop = () => { diff --git a/platform/assets/package-lock.json b/platform/assets/package-lock.json index e6dd40ba8..d5e010eba 100644 --- a/platform/assets/package-lock.json +++ b/platform/assets/package-lock.json @@ -13,6 +13,7 @@ "mapbox-gl": "^3.0.0", "maplibre-gl": "^4.3.2", "mark.js": "^8.11.1", + "sortablejs": "^1.15.2", "tippy.js": "^6.3.7", "tom-select": "^2.0.1", "vega": "^5.22.1", @@ -2184,6 +2185,11 @@ "node": ">=0.10.0" } }, + "node_modules/sortablejs": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz", + "integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA==" + }, "node_modules/source-map-js": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", diff --git a/platform/assets/package.json b/platform/assets/package.json index 66cf5702b..fca5e75c6 100644 --- a/platform/assets/package.json +++ b/platform/assets/package.json @@ -14,6 +14,7 @@ "mapbox-gl": "^3.0.0", "maplibre-gl": "^4.3.2", "mark.js": "^8.11.1", + "sortablejs": "^1.15.2", "tippy.js": "^6.3.7", "tom-select": "^2.0.1", "vega": "^5.22.1", diff --git a/platform/assets/tailwind.config.js b/platform/assets/tailwind.config.js index e777b45f6..a7c84897f 100644 --- a/platform/assets/tailwind.config.js +++ b/platform/assets/tailwind.config.js @@ -29,8 +29,12 @@ module.exports = { require('@tailwindcss/forms'), require('@tailwindcss/typography'), require('a17t'), - plugin(function({ addVariant }) { + plugin(function ({ addVariant }) { addVariant('processing', '.processing &', 'processing') - }) + }), + plugin(({ addVariant }) => addVariant("drag-item", [".drag-item&", ".drag-item &"])), + plugin(({ addVariant }) => addVariant("drag-ghost", [".drag-ghost&", ".drag-ghost &"])), + // Useful for hiding placeholders when dragging; active when there is a sibling with `data-sortable-id` set + plugin(({ addVariant }) => addVariant("sibling-sortable", ["~ [data-sortable-id] ~ &", "~ [data-sortable-id] &"])), ] }; \ No newline at end of file diff --git a/platform/lib/platform/material/attribute.ex b/platform/lib/platform/material/attribute.ex index f48e685f8..00884abed 100644 --- a/platform/lib/platform/material/attribute.ex +++ b/platform/lib/platform/material/attribute.ex @@ -493,6 +493,26 @@ defmodule Platform.Material.Attribute do attributes(opts) |> Enum.filter(&(&1.deprecated != true)) end + def filter_attributes_to_group(attrs, group, project) do + if is_nil(project) do + raise ArgumentError, "group cannot be filtered without project" + end + + case group do + %Platform.Projects.ProjectAttributeGroup{member_ids: ids} -> + Enum.filter(attrs, fn a -> Enum.member?(ids, a.name) end) + + :core -> + Enum.filter(attrs, fn a -> is_atom(a.name) end) + + :unassigned -> + Enum.filter(attrs, fn a -> + not is_atom(a.name) and + Enum.all?(project.attribute_groups, fn g -> not Enum.member?(g.member_ids, a.name) end) + end) + end + end + @doc """ Get the names of the attributes that are available for the given media. Both nil and the empty list count as unset. @@ -500,18 +520,30 @@ defmodule Platform.Material.Attribute do """ def set_for_media(media, opts \\ []) do pane = Keyword.get(opts, :pane) + groups = Keyword.get(opts, :groups) + project = Keyword.get(opts, :project) - Enum.filter(attributes(opts), fn a -> - all_attrs = get_children(a.name) ++ [a] + attrs = + Enum.filter(attributes(opts), fn a -> + all_attrs = get_children(a.name) ++ [a] - Enum.any?(all_attrs, fn attr -> - val = Material.get_attribute_value(media, attr) + Enum.any?(all_attrs, fn attr -> + val = Material.get_attribute_value(media, attr) - val != nil && val != [] && val != %{"day" => "", "month" => "", "year" => ""} && - (pane == nil || attr.pane == pane || (attr.parent == a.name && a.pane == pane)) && - (attr.deprecated != true || Keyword.get(opts, :include_deprecated_attributes, false)) + val != nil && val != [] && val != %{"day" => "", "month" => "", "year" => ""} && + (pane == nil || attr.pane == pane || (attr.parent == a.name && a.pane == pane)) && + (attr.deprecated != true || Keyword.get(opts, :include_deprecated_attributes, false)) + end) end) - end) + + # Now, if group and project are both set, we need to filter out the attributes that are not in the group. + if not is_nil(groups) do + Enum.flat_map(groups, fn group -> + filter_attributes_to_group(attrs, group, project) + end) + else + attrs + end end @doc """ @@ -522,13 +554,25 @@ defmodule Platform.Material.Attribute do def unset_for_media(media, opts \\ []) do pane = Keyword.get(opts, :pane) set = set_for_media(media, opts) + groups = Keyword.get(opts, :groups) + project = Keyword.get(opts, :project) + + attrs = + attributes(opts) + |> Enum.filter( + &(!Enum.member?(set, &1) && + &1.deprecated != true && + (pane == nil || &1.pane == pane)) + ) - attributes(opts) - |> Enum.filter( - &(!Enum.member?(set, &1) && - &1.deprecated != true && - (pane == nil || &1.pane == pane)) - ) + # Now, if group and project are both set, we need to filter out the attributes that are not in the group. + if not is_nil(groups) do + Enum.flat_map(groups, fn group -> + filter_attributes_to_group(attrs, group, project) + end) + else + attrs + end end @doc """ @@ -1183,8 +1227,21 @@ defmodule Platform.Material.Attribute do defp verify_change_exists(changeset, attributes) do # Verify that at least one of the given attributes has changed. This is used # to ensure that users don't post updates that don't actually change anything. - - if Enum.any?(attributes, &Map.has_key?(changeset.changes, &1.schema_field)) do + if Enum.any?(attributes, fn attribute -> + case Map.get(changeset.changes, attribute.schema_field) do + nil -> + false + + changes when attribute.schema_field == :project_attributes -> + # For project_attributes, check if any sub-changeset has actual changes + Enum.any?(changes, fn sub_changeset -> + map_size(sub_changeset.changes) > 0 and Map.has_key?(sub_changeset.changes, :value) + end) + + _changes -> + true + end + end) do changeset else changeset diff --git a/platform/lib/platform/projects.ex b/platform/lib/platform/projects.ex index e8abf3bba..97a6532cb 100644 --- a/platform/lib/platform/projects.ex +++ b/platform/lib/platform/projects.ex @@ -4,7 +4,7 @@ defmodule Platform.Projects do """ import Ecto.Query, warn: false - alias Platform.Projects.ProjectAttribute + alias Platform.Projects.{ProjectAttribute, ProjectAttributeGroup} alias Platform.Repo alias Platform.Projects.Project @@ -215,6 +215,44 @@ defmodule Platform.Projects do |> Repo.update() end + @doc """ + Deletes an embedded custom project attribute group. Checks user permission. + + ## Examples + + iex> delete_project_attribute_group(project, "existing_id") + {:ok, %Project{}} + + iex> delete_project_attribute_group(project, "non_existing_id") + {:error, %Ecto.Changeset{}} + """ + def delete_project_attribute_group(%Project{} = project, id, user \\ nil) do + # Verify the user has permission to edit the project + unless is_nil(user) || Permissions.can_edit_project_metadata?(user, project) do + raise "User does not have permission to edit this project" + end + + # Verify the attribute exists + unless Enum.any?(project.attribute_groups, fn g -> g.id == id end) do + raise "Attribute does not exist" + end + + # Delete the attribute + change_project(project) + |> Ecto.Changeset.put_embed( + :attribute_groups, + project.attribute_groups + |> Enum.map(fn g -> + if g.id == id do + ProjectAttributeGroup.changeset(g) |> Map.put(:action, :delete) + else + g + end + end) + ) + |> Repo.update() + end + @doc """ Returns an `%Ecto.Changeset{}` for tracking project changes. diff --git a/platform/lib/platform/projects/project.ex b/platform/lib/platform/projects/project.ex index 4d57e1a97..e8d4e251d 100644 --- a/platform/lib/platform/projects/project.ex +++ b/platform/lib/platform/projects/project.ex @@ -15,6 +15,7 @@ defmodule Platform.Projects.Project do field(:should_sync_with_internet_archive, :boolean, default: false) embeds_many(:attributes, Platform.Projects.ProjectAttribute, on_replace: :delete) + embeds_many(:attribute_groups, Platform.Projects.ProjectAttributeGroup, on_replace: :delete) has_many(:media, Platform.Material.Media) has_many(:memberships, Platform.Projects.ProjectMembership) @@ -29,7 +30,8 @@ defmodule Platform.Projects.Project do def changeset(project, attrs) do project |> cast(attrs, [:name, :code, :color, :description, :should_sync_with_internet_archive]) - |> cast_embed(:attributes, required: false) + |> cast_embed(:attributes, required: false, sort_param: :position) + |> cast_embed(:attribute_groups, required: false, sort_param: :position) |> validate_required([:name, :code, :color]) |> then(fn changeset -> changeset diff --git a/platform/lib/platform/projects/project_attribute.ex b/platform/lib/platform/projects/project_attribute.ex index 8ea2c0106..c6ade72f4 100644 --- a/platform/lib/platform/projects/project_attribute.ex +++ b/platform/lib/platform/projects/project_attribute.ex @@ -14,6 +14,7 @@ defmodule Platform.Projects.ProjectAttribute do # empty string if not a decorator field(:decorator_for, :string, default: "") field(:enabled, :boolean, default: true) + field(:ordering, :integer, default: 0) # JSON array of options field(:options_json, :string, virtual: true) @@ -41,7 +42,16 @@ defmodule Platform.Projects.ProjectAttribute do |> then(&if &1 == "", do: Jason.encode!(attribute.options), else: &1) attribute - |> cast(attrs, [:name, :type, :options_json, :id, :description, :decorator_for, :enabled]) + |> cast(attrs, [ + :name, + :ordering, + :type, + :options_json, + :id, + :description, + :decorator_for, + :enabled + ]) |> cast(%{options_json: json_options}, [:options_json]) |> cast( %{options: Jason.decode!(json_options)}, diff --git a/platform/lib/platform/projects/project_attribute_group.ex b/platform/lib/platform/projects/project_attribute_group.ex new file mode 100644 index 000000000..813cedef3 --- /dev/null +++ b/platform/lib/platform/projects/project_attribute_group.ex @@ -0,0 +1,30 @@ +defmodule Platform.Projects.ProjectAttributeGroup do + use Ecto.Schema + import Ecto.Changeset + + @derive {Jason.Encoder, only: [:name]} + @primary_key {:id, :binary_id, autogenerate: true} + embedded_schema do + field(:name, :string) + field(:description, :string, default: "") + field(:color, :string, default: "#808080") + field(:show_in_creation_form, :boolean, default: true) + # These can be a mix of binary IDs and string attributes, for core vs custom attributes + # Source of truth for membership but not ordering + field(:member_ids, {:array, :string}, default: []) + field(:ordering, :integer, default: 0) + end + + @doc """ + Changeset for project attribute groups. + """ + def changeset(%__MODULE__{} = attribute, attrs \\ %{}) do + attribute + |> cast(attrs, [:name, :show_in_creation_form, :member_ids, :description, :ordering, :color]) + # Validate that "color" matches a hex color code via regex + |> validate_format(:color, ~r/^#[0-9a-fA-F]{6}$/) + |> validate_length(:name, min: 1, max: 240) + |> validate_length(:description, max: 3000) + |> validate_required(:name) + end +end diff --git a/platform/lib/platform/workers/archiver.ex b/platform/lib/platform/workers/archiver.ex index 934296057..d5612bb2a 100644 --- a/platform/lib/platform/workers/archiver.ex +++ b/platform/lib/platform/workers/archiver.ex @@ -111,18 +111,23 @@ defmodule Platform.Workers.Archiver do end) |> Enum.filter(&(&1 != :skip)) - # Update the media version - version_map = %{ - status: :complete, - # Append the artifacts; some may already exist - artifacts: Enum.map(version.artifacts || [], &Map.from_struct(&1)) ++ artifacts, - metadata: %{ + # Combine the old metadata and the new metadata (we don't want to override!) + # TODO: Move these into their own internal namespace + combined_metadata = + Map.merge(version.metadata || %{}, %{ auto_archive_successful: Map.get(metadata, "auto_archive_successful", false), crawl_successful: Map.get(metadata, "crawl_successful", false), page_info: Map.get(metadata, "page_info"), content_info: Map.get(metadata, "content_info"), is_likely_authwalled: Map.get(metadata, "is_likely_authwalled", false) - } + }) + + # Update the media version + version_map = %{ + status: :complete, + # Append the artifacts; some may already exist + artifacts: Enum.map(version.artifacts || [], &Map.from_struct(&1)) ++ artifacts, + metadata: combined_metadata } {:ok, version} = Material.update_media_version(version, version_map) diff --git a/platform/lib/platform_web/components.ex b/platform/lib/platform_web/components.ex index de65de66a..c0dbba040 100644 --- a/platform/lib/platform_web/components.ex +++ b/platform/lib/platform_web/components.ex @@ -1328,7 +1328,7 @@ defmodule PlatformWeb.Components do <% end %> <%= if length(@unset_attrs) > 0 do %>
-
Add Attributes
+
Add Attributes
<%= for attr <- @unset_attrs do %> <.link @@ -1561,7 +1561,7 @@ defmodule PlatformWeb.Components do ~H"""
-
+
<%= @attr.label %> <%= if Platform.Material.Attribute.requires_privileges_to_edit(@attr) do %> diff --git a/platform/lib/platform_web/live/media_live/index.ex b/platform/lib/platform_web/live/media_live/index.ex index e3535d7c8..b2417686d 100644 --- a/platform/lib/platform_web/live/media_live/index.ex +++ b/platform/lib/platform_web/live/media_live/index.ex @@ -98,10 +98,7 @@ defmodule PlatformWeb.MediaLive.Index do :user_projects, Platform.Projects.list_projects_for_user(socket.assigns.current_user) ) - |> assign( - :attributes, - Attribute.active_attributes(project: active_project) |> Enum.filter(&is_nil(&1.parent)) - ) + |> assign_attributes(active_project) |> then(fn s -> if display == "table", do: @@ -118,17 +115,57 @@ defmodule PlatformWeb.MediaLive.Index do end) end + def assign_attributes(socket, project) do + attributes = Attribute.active_attributes(project: project) |> Enum.filter(&is_nil(&1.parent)) + + groups = + case dbg(project) do + nil -> [:core] + _ -> project.attribute_groups + end + + attributes_with_groups = + Enum.map(attributes, fn a -> + member_of = Enum.find(groups, &(not is_atom(&1) and Enum.member?(&1.member_ids, a.name))) + + group = + cond do + not is_nil(member_of) -> member_of + is_atom(a.name) -> :core + true -> :unassigned + end + + {a, group} + end) + + # Sort by group ordering, then attribute ordering + attributes_with_groups = + Enum.sort_by(attributes_with_groups, fn {_, g} -> + {if(is_atom(dbg(g)), do: -1, else: g.ordering)} + end) + + socket + |> assign( + :attributes, + attributes + ) + |> assign( + :attributes_with_groups, + attributes_with_groups + ) + end + def handle_params(params, _uri, socket) do # Wrap and catch CastErrors, in which case we put a flash and redirect to /incidents - try do - {:noreply, handle_params_internal(params, socket)} - rescue - _error -> - {:noreply, - socket - |> put_flash(:error, "Your search had invalid parameters. Please try again.") - |> push_patch(to: "/incidents", replace: true)} - end + # try do + {:noreply, handle_params_internal(params, socket)} + # rescue + # _error -> + # {:noreply, + # socket + # |> put_flash(:error, "Your search had invalid parameters. Please try again.") + # |> push_patch(to: "/incidents", replace: true)} + # end end defp assign_media(socket, media) do diff --git a/platform/lib/platform_web/live/media_live/index.html.heex b/platform/lib/platform_web/live/media_live/index.html.heex index 4abc3d5cd..98b9850b4 100644 --- a/platform/lib/platform_web/live/media_live/index.html.heex +++ b/platform/lib/platform_web/live/media_live/index.html.heex @@ -375,10 +375,15 @@ > Incident - <%= for attr <- @attributes do %> + <%= for {attr, group} <- @attributes_with_groups do %> <%= attr.label %> @@ -531,7 +536,7 @@
- <%= for attr <- @attributes do %> + <%= for {attr, group} <- @attributes_with_groups do %> namespace, "version" => version_id} = _params, + socket + ) do + version = Material.get_media_version!(version_id) + + if version.media_id != socket.assigns.media.id or + not Permissions.can_edit_media?(socket.assigns.current_user, socket.assigns.media) do + raise PlatformWeb.Errors.Unauthorized, "No permission" + end + + updated_metadata = Map.drop(version.metadata || %{}, [namespace]) + + {:ok, _} = Material.update_media_version(version, %{metadata: updated_metadata}) + + {:noreply, + socket + |> assign_media_and_updates() + |> put_flash(:info, "Metadata cleared successfully.")} + end + def handle_event( "set_media_visibility", %{"version" => version, "state" => value} = _params, diff --git a/platform/lib/platform_web/live/media_live/show.html.heex b/platform/lib/platform_web/live/media_live/show.html.heex index 00d7df35f..9c7a99684 100644 --- a/platform/lib/platform_web/live/media_live/show.html.heex +++ b/platform/lib/platform_web/live/media_live/show.html.heex @@ -8,35 +8,7 @@ if Platform.Material.Media.is_sensitive(@media), do: " (#{Enum.join(@media.attr_sensitive, ", ")})", else: "" %> - - <% title_classes = - "text-xl font-medium leading-7 text-neutral-700 sm:text-3xl block transition p-1 rounded " <> - if @media.deleted, do: "line-through ", else: "" %> - <%= if( - Permissions.can_edit_media?(@current_user, @media, Attribute.get_attribute(:description)) - ) do %> - <.link - class={title_classes <> " hover:bg-neutral-200 focus:bg-neutral-200"} - patch={Routes.media_show_path(@socket, :edit, @media.slug, :description)} - data-tooltip="Edit description" - replace={true} - > - <%= @media.attr_description %> - - <% else %> -
- <%= @media.attr_description %> -
- <% end %> -
+
<%= if @media.attr_status do %> <.link class={"chip @high inline-block mt-2 flex gap-1 " <> Attribute.attr_color(:status, @media.attr_status)} @@ -46,7 +18,7 @@ <.attribute_icon name={:status} value={@media.attr_status} - class="h-4 w-4" + class="h-4 w-4 opacity-75" type={:solid} /> <%= @media.attr_status %> @@ -61,7 +33,7 @@ > @@ -79,7 +51,7 @@
@@ -93,6 +65,37 @@
<% end %>
+

+ + <% title_classes = + "text-xl font-medium leading-7 text-neutral-700 sm:text-2xl transition p-1 -m-1 rounded " <> + if @media.deleted, do: "line-through ", else: "" %> + <%= if( + Permissions.can_edit_media?(@current_user, @media, Attribute.get_attribute(:description)) + ) do %> +   + <.link + class={title_classes <> " hover:bg-neutral-200 focus:bg-neutral-200"} + patch={Routes.media_show_path(@socket, :edit, @media.slug, :description)} + data-tooltip="Edit description" + replace={true} + > + <%= @media.attr_description %> + + <% else %> + + <%= @media.attr_description %> + + <% end %> +

<.live_component @@ -222,25 +225,61 @@
- <.card> + <.card no_pad={true} class="overflow-hidden"> <:header>

Attributes

-
- <.attr_display_block - set_attrs={ - Attribute.set_for_media(@media, pane: :attributes, project: @media.project) - } - unset_attrs={ - Attribute.unset_for_media(@media, pane: :attributes, project: @media.project) - |> filter_editable(@media, @current_user) - } - media={@media} - updates={@updates} - socket={@socket} - current_user={@current_user} - membership={@membership} - /> +
+
+
+
+

+ + <%= group.name %> + +

+

+ <%= group.description %> +

+
+ <.attr_display_block + set_attrs={ + Attribute.set_for_media(@media, + pane: :attributes, + project: @media.project, + groups: + if(group == :core_and_unassigned, do: [:core, :unassigned], else: [group]) + ) + } + unset_attrs={ + Attribute.unset_for_media(@media, + pane: :attributes, + project: @media.project, + groups: + if(group == :core_and_unassigned, do: [:core, :unassigned], else: [group]) + ) + |> filter_editable(@media, @current_user) + } + media={@media} + updates={@updates} + socket={@socket} + current_user={@current_user} + membership={@membership} + /> +
+
<.card> @@ -761,6 +800,40 @@ <% end %>
+
+ + View and manage metadata + +
+

+ Source material metadata is an advanced feature, and most users don't need to use it. Some metadata is set automatically by Atlos (e.g., "auto_archive_successful" and "content_info") while others are set by integrations. +

+
+
+
+
+

+ <%= namespace %> +

+ +
+
<%= Jason.encode!(version.metadata[namespace], pretty: true) |> String.trim(" \t\n") %>
+
+
+

Atlos provides best-effort archival. Archives may be incomplete or missing, and should not be relied on for legal evidence.

diff --git a/platform/lib/platform_web/live/new_live/new_component.ex b/platform/lib/platform_web/live/new_live/new_component.ex index 13c9a8604..6c2016324 100644 --- a/platform/lib/platform_web/live/new_live/new_component.ex +++ b/platform/lib/platform_web/live/new_live/new_component.ex @@ -353,19 +353,44 @@ defmodule PlatformWeb.NewLive.NewComponent do <% end %> <%= if not is_nil(@project) and not Enum.empty?(@project.attributes) do %> -
-
- <.edit_attributes - attrs={@project_attributes |> Enum.filter(&(&1.is_decorator != true))} - include_decorators={@project_attributes} - form={@form} - media_slug="NEW" - media={nil} - optional={true} - current_user={@current_user} - project={@project} - /> -
+
+
+

+ <%= group.name %> +

+

+ <%= group.description %> +

+
+ <% group_id = if group == :unassigned, do: "unassigned", else: group.id %> +
+ <.edit_attributes + attrs={ + @project_attributes + |> Enum.filter(&(&1.is_decorator != true)) + |> Platform.Material.Attribute.filter_attributes_to_group( + group, + @project + ) + } + include_decorators={@project_attributes} + form={@form} + media_slug="NEW" + media={nil} + optional={true} + current_user={@current_user} + project={@project} + /> +
+
<% end %>
diff --git a/platform/lib/platform_web/live/projects_live/edit_component.ex b/platform/lib/platform_web/live/projects_live/edit_component.ex index 27f33a4e6..b28725443 100644 --- a/platform/lib/platform_web/live/projects_live/edit_component.ex +++ b/platform/lib/platform_web/live/projects_live/edit_component.ex @@ -1,7 +1,7 @@ defmodule PlatformWeb.ProjectsLive.EditComponent do use PlatformWeb, :live_component - alias Platform.Projects.ProjectAttribute + alias Platform.Projects.{ProjectAttribute, ProjectAttributeGroup} alias Platform.Auditor alias Platform.Projects alias Platform.Permissions @@ -22,8 +22,7 @@ defmodule PlatformWeb.ProjectsLive.EditComponent do {:ok, socket - # Either nil, :new, :decorators, or a UUID - |> assign(:actively_editing_id, nil) + |> close_modal() |> assign_new(:general_changeset, fn -> Projects.change_project(socket.assigns.project) end) @@ -36,6 +35,12 @@ defmodule PlatformWeb.ProjectsLive.EditComponent do |> assign_new(:show_panes, fn -> [:general, :custom_attributes] end)} end + defp close_modal(socket) do + socket + |> assign(:actively_editing_attribute_id, nil) + |> assign(:actively_editing_group_id, nil) + end + def assign_general_changeset(socket, attrs \\ %{}) do socket |> assign( @@ -123,7 +128,8 @@ defmodule PlatformWeb.ProjectsLive.EditComponent do ) do {:ok, project} -> send(self(), {:project_saved, project}) - {:noreply, socket |> assign(project: project) |> assign(:actively_editing_id, nil)} + + {:noreply, socket |> assign(project: project) |> close_modal()} {:error, changeset} -> {:noreply, @@ -145,7 +151,7 @@ defmodule PlatformWeb.ProjectsLive.EditComponent do {:noreply, socket |> assign(project: project) - |> assign(:actively_editing_id, nil) + |> close_modal() |> assign_custom_attribute_changeset()} {:error, changeset} -> @@ -165,7 +171,23 @@ defmodule PlatformWeb.ProjectsLive.EditComponent do existing ++ [%ProjectAttribute{}] ) end) - |> assign(:actively_editing_id, :new) + |> assign(:actively_editing_attribute_id, :new) + + {:noreply, socket} + end + + def handle_event("add_attr_group", _, socket) do + socket = + update(socket, :custom_attribute_changeset, fn changeset -> + existing = Ecto.Changeset.get_field(changeset, :attribute_groups, []) + + Ecto.Changeset.put_embed( + changeset, + :attribute_groups, + existing ++ [%ProjectAttributeGroup{}] + ) + end) + |> assign(:actively_editing_group_id, :new) {:noreply, socket} end @@ -209,7 +231,7 @@ defmodule PlatformWeb.ProjectsLive.EditComponent do end) ) end) - |> assign(:actively_editing_id, :decorators) + |> assign(:actively_editing_attribute_id, :decorators) {:noreply, socket} end @@ -224,16 +246,41 @@ defmodule PlatformWeb.ProjectsLive.EditComponent do {:noreply, socket |> assign(:project, project) - |> assign(:actively_editing_id, nil) + |> close_modal() |> assign_custom_attribute_changeset()} end - def handle_event("open_modal", %{"id" => id}, socket) do - {:noreply, socket |> assign(:actively_editing_id, id)} + def handle_event("delete_group", %{"id" => id}, socket) do + # Delete the attribute group, save, and close the actively editing modal + {:ok, project} = + Projects.delete_project_attribute_group( + socket.assigns.project, + id, + socket.assigns.current_user + ) + + send(self(), {:project_saved, project}) + + {:noreply, + socket + |> assign(:project, project) + |> assign(:actively_editing_group_id, nil) + |> assign_custom_attribute_changeset()} + end + + def handle_event("open_attr_edit_modal", %{"id" => id}, socket) do + {:noreply, socket |> assign(:actively_editing_attribute_id, id)} + end + + def handle_event("open_group_edit_modal", %{"id" => id}, socket) do + {:noreply, socket |> assign(:actively_editing_group_id, id)} end def handle_event("close_modal", _params, socket) do - {:noreply, socket |> assign(:actively_editing_id, nil) |> assign_custom_attribute_changeset()} + {:noreply, + socket + |> close_modal() + |> assign_custom_attribute_changeset()} end def type_mapping, @@ -249,6 +296,89 @@ defmodule PlatformWeb.ProjectsLive.EditComponent do # Invert type_mapping do: type_mapping() |> Enum.map(fn {k, v} -> {v, k} end) |> Enum.into(%{}) + def handle_event("reposition", params, socket) do + # Ensure we have the latest project + project = Platform.Projects.get_project!(socket.assigns.project.id) + + # Called when dragging an attribute to a new position + %{"group" => group_id, "ordering" => ordering} = params + + case group_id do + "group_ordering" -> + # Reorder groups + groups = + Enum.map(project.attribute_groups, fn g -> + idx = + case g do + %ProjectAttributeGroup{} -> Enum.find_index(ordering, &(&1 == g.id)) || 0 + _ -> 0 + end + + %{g | ordering: idx} + end) + |> Enum.sort_by(& &1.ordering) + |> Enum.map(fn g -> Map.from_struct(g) end) + + # Update the project with the new groups + {:ok, project} = + Projects.update_project( + project, + %{attribute_groups: groups}, + socket.assigns.current_user + ) + + send(self(), {:project_saved, project}) + + {:noreply, socket |> assign(project: project) |> assign_custom_attribute_changeset()} + + # For all the groups, if the group is the one we want to edit, update the ordering; if it's not, make sure that the none of the elements in the ordering are in that group + _ -> + new_groups = + Enum.map(project.attribute_groups, fn g -> + g = + if g.id == group_id do + %{g | member_ids: ordering} + else + %{ + g + | member_ids: Enum.reject(g.member_ids, fn id -> Enum.member?(ordering, id) end) + } + end + + Map.from_struct(g) + end) + + # Reorder the embedded attributes given their order in their respective + # groups. This is also what allows us to sort unassigned attributes. + new_attributes = + Enum.map(project.attributes, fn e -> + idx = + case Enum.find(new_groups, &Enum.member?(&1.member_ids, e.id)) do + # We can't find them in a group, so we check the given ordering + # (which allows reordering if the attribute is in the unassigned + # group) + nil -> Enum.find_index(ordering, &(&1 == e.id)) || -1 + group -> Enum.find_index(group.member_ids, &(&1 == e.id)) || -1 + end + + Map.from_struct(%{e | ordering: idx}) + end) + |> Enum.sort_by(& &1.ordering) + + # Update the project with the new groups + {:ok, project} = + Projects.update_project( + socket.assigns.project, + %{attribute_groups: new_groups, attributes: new_attributes}, + socket.assigns.current_user + ) + + send(self(), {:project_saved, project}) + + {:noreply, socket |> assign(project: project) |> assign_custom_attribute_changeset()} + end + end + def edit_custom_project_attribute(assigns) do # The attribute this is a decorator for assigns = @@ -313,7 +443,7 @@ defmodule PlatformWeb.ProjectsLive.EditComponent do key: k, value: v, disabled: - not Enum.member?( + not is_nil(Ecto.Changeset.get_field(@f_attr.source, :id)) and not Enum.member?( ProjectAttribute.compatible_types(Ecto.Changeset.get_field(@f_attr.source, :type)), v ) @@ -379,6 +509,85 @@ defmodule PlatformWeb.ProjectsLive.EditComponent do """ end + def edit_attribute_group(assigns) do + ~H""" + <%= inputs_for @f, :attribute_groups, [multipart: true, id: "attr-group-form-#{@group_id}"], fn ef -> %> + <%= if ef.data.id == @group_id or (is_nil(ef.data.id) and @group_id == :new) do %> +
+
+ <%= label(ef, :name, class: "!text-neutral-600 !font-normal") %> + <%= text_input(ef, :name, class: "my-1") %> + <%= error_tag(ef, :name) %> +
+
+ <%= label(ef, :description, class: "!text-neutral-600 !font-normal") %> + <%= text_input(ef, :description, class: "my-1") %> + <%= error_tag(ef, :description) %> +

+ Optional. The description will be displayed with this attribute group to provide additional context. +

+
+
+ <%= label(ef, :color, class: "!text-neutral-600 !font-normal") %> +
+
+ <%= for color <- ["#808080", "#fb923c", "#fbbf24", "#a3e635", "#4ade80", "#2dd4bf", "#22d3ee", "#60a5fa", "#818cf8", "#a78bfa", "#c084fc", "#e879f9", "#f472b6"] do %> + + <% end %> +
+
+ <%= error_tag(ef, :color) %> +

+ This color will help visually identify the attribute group. +

+
+
+

+ <%= checkbox(ef, :show_in_creation_form) %> + <%= label(ef, :show_in_creation_form, class: "!text-neutral-600 !font-normal") do %> + Include this group in the incident creation window + <% end %> +

+

+ If enabled, the attributes in this group are included in the incident creation window. If disabled, the group's attributes aren't shown in the incident creation window but can still be edited directly on the incident page. +

+ <%= error_tag(ef, :show_in_creation_form) %> +
+
+ <% end %> + <% end %> + """ + end + def decorator_description(assigns) do ~H""" @@ -389,36 +598,44 @@ defmodule PlatformWeb.ProjectsLive.EditComponent do def attribute_table_row(assigns) do ~H""" - - <%= @attr.label %> - - - <%= @attr.type - |> then(&Map.get(name_mapping(), &1)) %> - - - <%= @attr.description |> Platform.Utils.truncate(40) %> - - - <%= if @attr.schema_field == :project_attributes do %> - - <% else %> - +
+ - <% end %> - + <%= @attr.label %> +
+
+ <%= @attr.type + |> then(&Map.get(name_mapping(), &1)) %> +
+ +
+ <%= if @attr.schema_field == :project_attributes do %> + + <% else %> + + <% end %> +
+ """ end @@ -608,6 +825,30 @@ defmodule PlatformWeb.ProjectsLive.EditComponent do

Define the data model for incidents in this project. You can add new attributes, or edit the existing ones.

+
+ + +
<% end %>
@@ -627,120 +868,274 @@ defmodule PlatformWeb.ProjectsLive.EditComponent do <% end %>
-
-
- - - - - - -
+ <.modal + :if={@actively_editing_group_id == :new} + target={@myself} + id="new_group_editor" + js_on_close="document.cancelFormEvent($event)" + > +
+

Create Group

+
+ <.edit_attribute_group f={f} group_id={:new} group_ordering={-1} /> +
+
+ <%= submit("Save", class: "button ~urge @high") %> + +
+
+ +
+
+ <% attr_ids_to_group = + Enum.flat_map(@project.attribute_groups, fn attr_group -> + Enum.map(attr_group.member_ids, &{&1, attr_group.id}) + end) + |> Enum.into(%{}) %> +
+ <% group_id = if is_atom(group), do: group, else: to_string(group.id) %> +
+
+
+

+ +

+ + <%= case group do %> + <% :unassigned -> %> + Ungrouped Attributes + <% :core -> %> + Core Attributes + <% _ -> %> + <%= group.name %> + <% end %> + +
-
- - - <.inputs_for :let={f_attr} field={f[:attributes]}> - <%= if not is_nil(Ecto.Changeset.get_field(f_attr.source, :id)) and Ecto.Changeset.get_field(f_attr.source, :decorator_for) == "" do %> - - <.attribute_table_row - attr={ProjectAttribute.to_attribute(f_attr.data)} - myself={@myself} - show_edit_button={ - Permissions.can_edit_project_metadata?(@current_user, @project) - } - /> - - <% end %> - <%= if (@actively_editing_id == Ecto.Changeset.get_field(f_attr.source, :id)) || (Ecto.Changeset.get_field(f_attr.source, :id) == nil and @actively_editing_id == :new) do %> - <.modal - target={@myself} - id={(@actively_editing_id || "nil") |> to_string()} - js_on_close="document.cancelFormEvent($event)" - > -
-

Edit Custom Attribute

-
- <.edit_custom_project_attribute f_attr={f_attr} /> -
-
- <%= submit("Save", class: "button ~urge @high") %> - +

+ <%= case group do %> + <% :unassigned -> %> + Ungrouped attributes are not associated with any group. + <% :core -> %> + Core attributes are required for all incidents. You can't modify core attributes. + <% _ -> %> + <%= group.description %> + <% end %> +

+
+ <%= case group do %> + <% :core -> %> +
+ <.attribute_table_row + attr={attr} + myself={@myself} + editable={false} + />
- <%= if Ecto.Changeset.get_field(f_attr.source, :id) do %> - +
+

Edit Group

+
+ <.edit_attribute_group + f={f} + group_id={group_id} + group_ordering={group.ordering} + /> +
+
+ <%= submit("Save", class: "button ~urge @high") %> + +
+ +
+ + <% else %> + <% end %> -
- - <% else %> - - <% end %> - - <%= for attr <- get_core_attributes() do %> -
- <.attribute_table_row - attr={attr} - myself={@myself} - show_edit_button={false} - /> - - <% end %> - -
- Name - - Type -
- <%= if @actively_editing_id == :decorators do %> +
+
+ There are no attributes in this group. Drag attributes into this group to add them. +
+ <.inputs_for + :let={f_attr} + id={"attr-form-#{group_id}"} + field={f[:attributes]} + > + <% attr_group_id = + attr_ids_to_group[ + Ecto.Changeset.get_field(f_attr.source, :id) + ] || :unassigned %> + <%= if Ecto.Changeset.get_field(f_attr.source, :decorator_for) == "" and (attr_group_id == group_id) do %> +
+
+ <.attribute_table_row + attr={ProjectAttribute.to_attribute(f_attr.data)} + myself={@myself} + editable={ + Permissions.can_edit_project_metadata?( + @current_user, + @project + ) + } + /> +
+ <%= if @actively_editing_attribute_id == Ecto.Changeset.get_field(f_attr.source, :id) or (is_nil(Ecto.Changeset.get_field(f_attr.source, :id)) and @actively_editing_attribute_id == :new) do %> + <.modal + target={@myself} + id={ + (@actively_editing_attribute_id || "nil") + |> to_string() + } + js_on_close="document.cancelFormEvent($event)" + > +
+

Edit Custom Attribute

+
+ <.edit_custom_project_attribute f_attr={f_attr} /> +
+
+ <%= submit("Save", class: "button ~urge @high") %> + +
+ <%= if Ecto.Changeset.get_field(f_attr.source, :id) do %> + + <% end %> +
+ + <% else %> + + <% end %> +
+ <% end %> + +
+ <% end %> +
+
+
+ + <%= if @actively_editing_attribute_id == :decorators do %> <.modal target={@myself} id="decorator_edit" @@ -802,7 +1197,7 @@ defmodule PlatformWeb.ProjectsLive.EditComponent do
-

+

Decorators