+ <%= namespace %> +
+ +<%= Jason.encode!(version.metadata[namespace], pretty: true) |> String.trim(" \t\n") %>+
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 %>
+ + <% 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 %> +
+ + <%= group.name %> + +
++ <%= group.description %> +
++ 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 %> -+ <%= group.description %> +
++ Optional. The description will be displayed with this attribute group to provide additional context. +
++ 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) %> +Define the data model for incidents in this project. You can add new attributes, or edit the existing ones.
+