From 4b142e7b2379de8f91cfa3f4c12258159c25343f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pr=C3=A9vost?= Date: Fri, 16 Dec 2022 08:56:46 -0500 Subject: [PATCH] wip --- config/config.exs | 17 +++ config/dev.exs | 9 -- config/prod.exs | 9 -- config/test.exs | 6 - lib/accent/telemetry_ui.ex | 16 ++- lib/graphql/resolvers/collaborator.ex | 2 +- lib/graphql/types/integration.ex | 5 +- lib/hook/outbounds/discord.ex | 20 --- lib/hook/outbounds/discord/discord.ex | 9 ++ lib/hook/outbounds/discord/templates.ex | 22 ++++ lib/hook/outbounds/discord/templates/sync.eex | 4 - lib/hook/outbounds/email.ex | 4 +- lib/hook/outbounds/post_url.ex | 45 +++++-- lib/hook/outbounds/slack.ex | 20 --- lib/hook/outbounds/slack/slack.ex | 9 ++ lib/hook/outbounds/slack/templates.ex | 22 ++++ lib/hook/outbounds/slack/templates/sync.eex | 4 - .../utils/line_by_line_helper/parser.ex | 1 + lib/movement/persisters/base.ex | 13 ++ .../persisters/project_state_change_worker.ex | 88 +++++++++++++ lib/web/controllers/merge_controller.ex | 2 +- lib/web/emails/create_comment_email.ex | 6 +- .../templates/email/create_comment.html.eex | 2 +- .../templates/email/create_comment.text.eex | 2 +- .../templates/email/project_invite.html.eex | 2 +- .../templates/email/project_invite.text.eex | 2 +- lib/web/views/email_view.ex | 4 + test/emails/create_comment_email_test.exs | 3 +- test/hook/outbounds/discord_test.exs | 4 +- test/hook/outbounds/email_test.exs | 6 +- test/hook/outbounds/slack_test.exs | 4 +- .../project_state_change_worker_test.exs | 120 ++++++++++++++++++ .../web/controllers/merge_controller_test.exs | 6 +- webapp/app/helpers/array-includes.ts | 7 + webapp/app/locales/en-us.json | 4 +- .../project-navigation/list/styles.scss | 8 +- .../project-navigation/list/template.hbs | 26 ++-- .../integrations/form/component.ts | 13 +- .../form/data-control-checkboxes/component.ts | 32 ++++- .../form/data-control-checkboxes/styles.scss | 13 +- .../form/data-control-checkboxes/template.hbs | 18 +-- .../integrations/form/discord/component.ts | 4 +- .../integrations/form/discord/template.hbs | 5 +- .../integrations/form/github/component.ts | 2 - .../integrations/form/slack/component.ts | 4 +- .../integrations/form/slack/template.hbs | 5 +- .../integrations/form/styles.scss | 1 + .../integrations/form/template.hbs | 4 +- webapp/app/styles/app.scss | 3 +- webapp/public/assets/services/github.svg | 2 +- 50 files changed, 472 insertions(+), 167 deletions(-) delete mode 100644 lib/hook/outbounds/discord.ex create mode 100644 lib/hook/outbounds/discord/discord.ex create mode 100644 lib/hook/outbounds/discord/templates.ex delete mode 100644 lib/hook/outbounds/discord/templates/sync.eex delete mode 100644 lib/hook/outbounds/slack.ex create mode 100644 lib/hook/outbounds/slack/slack.ex create mode 100644 lib/hook/outbounds/slack/templates.ex delete mode 100644 lib/hook/outbounds/slack/templates/sync.eex create mode 100644 lib/movement/persisters/project_state_change_worker.ex create mode 100644 test/movement/persisters/project_state_change_worker_test.exs create mode 100644 webapp/app/helpers/array-includes.ts diff --git a/config/config.exs b/config/config.exs index a3c57143..0d2db341 100644 --- a/config/config.exs +++ b/config/config.exs @@ -12,6 +12,23 @@ if config_env() == :dev do config :accent, Accent.Repo, log: false end +if config_env() == :test do + events = ~w(sync add_translations create_collaborator create_comment complete_review new_conflicts) + + config :accent, Accent.Hook, + outbounds: [{Accent.Hook.Outbounds.Mock, events: events}], + inbounds: [{Accent.Hook.Inbounds.Mock, events: events}] +else + config :accent, Accent.Hook, + outbounds: [ + {Accent.Hook.Outbounds.Discord, events: ~w(sync complete_review new_conflicts)}, + {Accent.Hook.Outbounds.Email, events: ~w(create_collaborator create_comment)}, + {Accent.Hook.Outbounds.Slack, events: ~w(sync complete_review new_conflicts)}, + {Accent.Hook.Outbounds.Websocket, events: ~w(sync create_collaborator create_comment complete_review new_conflicts)} + ], + inbounds: [{Accent.Hook.Inbounds.GitHub, events: ~w(sync)}] +end + config :accent, Accent.Endpoint, render_errors: [accepts: ~w(json)], pubsub_server: Accent.PubSub diff --git a/config/dev.exs b/config/dev.exs index e0151320..2521ba12 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -26,15 +26,6 @@ config :accent, Accent.Endpoint, ] ] -config :accent, Accent.Hook, - outbounds: [ - {Accent.Hook.Outbounds.Discord, events: ~w(sync)}, - {Accent.Hook.Outbounds.Email, events: ~w(create_collaborator create_comment)}, - {Accent.Hook.Outbounds.Slack, events: ~w(sync)}, - {Accent.Hook.Outbounds.Websocket, events: ~w(sync create_collaborator create_comment)} - ], - inbounds: [{Accent.Hook.Inbounds.GitHub, events: ~w(sync)}] - config :logger, :console, format: "$metadata[$level] $message\n", metadata: ~w(current_user graphql_operation hook_service hook_url)a diff --git a/config/prod.exs b/config/prod.exs index 25cb4a97..63b3af08 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -4,15 +4,6 @@ config :accent, Accent.Endpoint, check_origin: false, server: true -config :accent, Accent.Hook, - outbounds: [ - {Accent.Hook.Outbounds.Discord, events: ~w(sync)}, - {Accent.Hook.Outbounds.Email, events: ~w(create_collaborator create_comment)}, - {Accent.Hook.Outbounds.Slack, events: ~w(sync)}, - {Accent.Hook.Outbounds.Websocket, events: ~w(sync create_collaborator create_comment)} - ], - inbounds: [{Accent.Hook.Inbounds.GitHub, events: ~w(sync)}] - config :logger, :console, format: "$time $metadata[$level] $message\n", level: :info, diff --git a/config/test.exs b/config/test.exs index 15c632ae..95a98a51 100644 --- a/config/test.exs +++ b/config/test.exs @@ -10,12 +10,6 @@ config :ueberauth, Ueberauth, providers: [{:dummy, {Accent.Auth.Ueberauth.DummyS config :accent, Oban, crontab: false, queues: false, plugins: false -events = ~w(sync merge create_collaborator create_comment) - -config :accent, Accent.Hook, - outbounds: [{Accent.Hook.Outbounds.Mock, events: events}], - inbounds: [{Accent.Hook.Inbounds.Mock, events: events}] - config :goth, disabled: true config :tesla, logger_enabled: false, adapter: Tesla.Mock diff --git a/lib/accent/telemetry_ui.ex b/lib/accent/telemetry_ui.ex index 10a62296..653ff919 100644 --- a/lib/accent/telemetry_ui.ex +++ b/lib/accent/telemetry_ui.ex @@ -106,7 +106,11 @@ defmodule Accent.TelemetryUI do defp absinthe_metrics do absinthe_tag_values = fn metadata -> - operation_name = Enum.map_join(metadata.blueprint.operations, ",", & &1.name) + operation_name = + metadata.blueprint.operations + |> Enum.map(& &1.name) + |> Enum.uniq() + |> Enum.join(",") %{operation_name: operation_name} end @@ -147,8 +151,14 @@ defmodule Accent.TelemetryUI do graphql_tag_values = fn metadata -> operation_name = case metadata.conn.params do - %{"_json" => json} -> Enum.map_join(json, ",", & &1["operationName"]) - _ -> nil + %{"_json" => json} -> + json + |> Enum.map(& &1["operationName"]) + |> Enum.uniq() + |> Enum.join(",") + + _ -> + nil end %{operation_name: operation_name} diff --git a/lib/graphql/resolvers/collaborator.ex b/lib/graphql/resolvers/collaborator.ex index ec152f2e..3e9ac71c 100644 --- a/lib/graphql/resolvers/collaborator.ex +++ b/lib/graphql/resolvers/collaborator.ex @@ -22,7 +22,7 @@ defmodule Accent.GraphQL.Resolvers.Collaborator do case CollaboratorCreator.create(params) do {:ok, collaborator} -> - Accent.Hook.outbound(%Hook.Context{ + Hook.outbound(%Hook.Context{ event: "create_collaborator", project_id: project.id, user_id: info.context[:conn].assigns[:current_user].id, diff --git a/lib/graphql/types/integration.ex b/lib/graphql/types/integration.ex index 92563b9d..7f4b65cc 100644 --- a/lib/graphql/types/integration.ex +++ b/lib/graphql/types/integration.ex @@ -9,7 +9,10 @@ defmodule Accent.GraphQL.Types.Integration do enum :project_integration_event do value(:sync, as: "sync") - value(:merge, as: "merge") + value(:new_conflicts, as: "new_conflicts") + value(:complete_review, as: "complete_review") + value(:create_collaborator, as: "create_collaborator") + value(:create_comment, as: "create_comment") end interface :project_integration do diff --git a/lib/hook/outbounds/discord.ex b/lib/hook/outbounds/discord.ex deleted file mode 100644 index 5226f39c..00000000 --- a/lib/hook/outbounds/discord.ex +++ /dev/null @@ -1,20 +0,0 @@ -defmodule Accent.Hook.Outbounds.Discord do - use Oban.Worker, queue: :hook - require EEx - - EEx.function_from_file(:def, :sync, "lib/hook/outbounds/discord/templates/sync.eex", [:assigns], trim: true) - - alias Accent.Hook.Outbounds.PostURL - - @impl Oban.Worker - def perform(%Oban.Job{args: args}) do - context = Accent.Hook.Context.from_worker(args) - PostURL.perform("discord", &build_body/1).(context) - end - - defp build_body(%{event: "sync", user: user, payload: %{"document_path" => document_path, "batch_operation_stats" => stats}}) do - %{ - text: sync(user: user, document_path: document_path, stats: stats) - } - end -end diff --git a/lib/hook/outbounds/discord/discord.ex b/lib/hook/outbounds/discord/discord.ex new file mode 100644 index 00000000..42123ff2 --- /dev/null +++ b/lib/hook/outbounds/discord/discord.ex @@ -0,0 +1,9 @@ +defmodule Accent.Hook.Outbounds.Discord do + use Oban.Worker, queue: :hook + + @impl Oban.Worker + def perform(%Oban.Job{args: args}) do + context = Accent.Hook.Context.from_worker(args) + Accent.Hook.Outbounds.PostURL.perform("discord", context, Hook.Outbounds.Discord.Templates) + end +end diff --git a/lib/hook/outbounds/discord/templates.ex b/lib/hook/outbounds/discord/templates.ex new file mode 100644 index 00000000..69d7367e --- /dev/null +++ b/lib/hook/outbounds/discord/templates.ex @@ -0,0 +1,22 @@ +defmodule Hook.Outbounds.Discord.Templates do + require EEx + + @sync_template """ + **<%= @user %>** just synced a file: *<%= @document_path %>* + + **Stats:**<%= for %{"action" => action, "count" => count} <- @stats do %> + <%= Phoenix.Naming.humanize(action) %>: *<%= count %>*<% end %> + """ + @new_conflicts_template """ + **<%= @user %>** just added *<%= @new_conflicts_count %> strings* to review. + The project is currently **<%= @reviewed_count / @translations_count %>** reviewed (<%= @reviewed_count %>/<%= @translations_count %>) + """ + @complete_review_template """ + **<%= @user %>** just finished reviewing all strings! + The project currently has <%= @translations_count %> reviewed translations. + """ + + EEx.function_from_string(:def, :sync, @sync_template, [:assigns], trim: true) + EEx.function_from_string(:def, :new_conflicts, @new_conflicts_template, [:assigns], trim: true) + EEx.function_from_string(:def, :complete_review, @complete_review_template, [:assigns], trim: true) +end diff --git a/lib/hook/outbounds/discord/templates/sync.eex b/lib/hook/outbounds/discord/templates/sync.eex deleted file mode 100644 index 0f1a4cb8..00000000 --- a/lib/hook/outbounds/discord/templates/sync.eex +++ /dev/null @@ -1,4 +0,0 @@ -**<%= @user.fullname %>** just synced a file: *<%= @document_path %>* - -**Stats:**<%= for %{"action" => action, "count" => count} <- @stats do %> -<%= action %>: *<%= count %>*<% end %> diff --git a/lib/hook/outbounds/email.ex b/lib/hook/outbounds/email.ex index 6ad54f18..e133692b 100644 --- a/lib/hook/outbounds/email.ex +++ b/lib/hook/outbounds/email.ex @@ -22,8 +22,8 @@ defmodule Accent.Hook.Outbounds.Email do ProjectInviteEmail.create(emails, user, project) end - defp build_email(emails, %{event: "create_comment", project: project, payload: payload}) do - CreateCommentEmail.create(emails, project, payload) + defp build_email(emails, %{event: "create_comment", project: project, user: user, payload: payload}) do + CreateCommentEmail.create(emails, user, project, payload) end defp fetch_emails(%{event: "create_collaborator", payload: payload}) do diff --git a/lib/hook/outbounds/post_url.ex b/lib/hook/outbounds/post_url.ex index 6099a812..3c54f62b 100644 --- a/lib/hook/outbounds/post_url.ex +++ b/lib/hook/outbounds/post_url.ex @@ -4,23 +4,22 @@ defmodule Accent.Hook.Outbounds.PostURL do import Ecto.Query, only: [where: 2] alias Accent.Repo + alias Accent.User - def perform(service, build_body) do - fn context = %{event: event, project: project} -> - body = build_body.(context) - integrations = filter_service_integration_events(project, event, service) - urls = Enum.map(integrations, & &1.data.url) + def perform(service, context, templates) do + urls = fetch_service_integration_urls(context.project, context.event, service) + body = build_body(templates, context) - post_urls(urls, body, service) - end + post_urls(urls, body, service) end - defp filter_service_integration_events(project, event, service) do + defp fetch_service_integration_urls(project, event, service) do project |> Ecto.assoc(:integrations) |> where(service: ^service) |> Repo.all() |> Enum.filter(&(event in &1.events)) + |> Enum.map(& &1.data.url) end defp post_urls(urls, body, service) do @@ -49,4 +48,34 @@ defmodule Accent.Hook.Outbounds.PostURL do defp formatted_diff(diff) when diff > 1000, do: [diff |> div(1000) |> Integer.to_string(), "ms"] defp formatted_diff(diff), do: [Integer.to_string(diff), "µs"] + + defp build_body(templates, %{event: "sync", user: user, payload: %{"document_path" => document_path, "batch_operation_stats" => stats}}) do + assigns = %{ + user: User.name_with_fallback(user), + document_path: document_path, + stats: stats + } + + %{text: templates.sync(assigns)} + end + + defp build_body(templates, %{event: "new_conflicts", user: user, payload: payload}) do + assigns = %{ + user: User.name_with_fallback(user), + reviewed_count: payload["reviewed_count"], + new_conflicts_count: payload["new_conflicts_count"], + translations_count: payload["translations_count"] + } + + %{text: templates.new_conflicts(assigns)} + end + + defp build_body(templates, %{event: "complete_review", user: user, payload: payload}) do + assigns = %{ + user: User.name_with_fallback(user), + translations_count: payload["translations_count"] + } + + %{text: templates.complete_review(assigns)} + end end diff --git a/lib/hook/outbounds/slack.ex b/lib/hook/outbounds/slack.ex deleted file mode 100644 index 0ea63535..00000000 --- a/lib/hook/outbounds/slack.ex +++ /dev/null @@ -1,20 +0,0 @@ -defmodule Accent.Hook.Outbounds.Slack do - use Oban.Worker, queue: :hook - require EEx - - EEx.function_from_file(:def, :sync, "lib/hook/outbounds/slack/templates/sync.eex", [:assigns], trim: true) - - alias Accent.Hook.Outbounds.PostURL - - @impl Oban.Worker - def perform(%Oban.Job{args: args}) do - context = Accent.Hook.Context.from_worker(args) - PostURL.perform("slack", &build_body/1).(context) - end - - defp build_body(%{event: "sync", user: user, payload: %{"document_path" => document_path, "batch_operation_stats" => stats}}) do - %{ - text: sync(user: user, document_path: document_path, stats: stats) - } - end -end diff --git a/lib/hook/outbounds/slack/slack.ex b/lib/hook/outbounds/slack/slack.ex new file mode 100644 index 00000000..170f2dba --- /dev/null +++ b/lib/hook/outbounds/slack/slack.ex @@ -0,0 +1,9 @@ +defmodule Accent.Hook.Outbounds.Slack do + use Oban.Worker, queue: :hook + + @impl Oban.Worker + def perform(%Oban.Job{args: args}) do + context = Accent.Hook.Context.from_worker(args) + Accent.Hook.Outbounds.PostURL.perform("slack", context, Hook.Outbounds.Slack.Templates) + end +end diff --git a/lib/hook/outbounds/slack/templates.ex b/lib/hook/outbounds/slack/templates.ex new file mode 100644 index 00000000..57621241 --- /dev/null +++ b/lib/hook/outbounds/slack/templates.ex @@ -0,0 +1,22 @@ +defmodule Hook.Outbounds.Slack.Templates do + require EEx + + @sync_template """ + *<%= @user %>* just synced a file: _<%= @document_path %>_ + + *Stats:*<%= for %{"action" => action, "count" => count} <- @stats do %> + <%= Phoenix.Naming.humanize(action) %>: _<%= count %>_<% end %> + """ + @new_conflicts_template """ + *<%= @user %>* just added _<%= @new_conflicts_count %> strings_ to review. + The project is currently *<%= @reviewed_count / @translations_count %>* reviewed (<%= @reviewed_count %>/<%= @translations_count %>) + """ + @complete_review_template """ + *<%= @user %>* just finished reviewing all strings! + The project currently has <%= @translations_count %> reviewed translations. + """ + + EEx.function_from_string(:def, :sync, @sync_template, [:assigns], trim: true) + EEx.function_from_string(:def, :new_conflicts, @new_conflicts_template, [:assigns], trim: true) + EEx.function_from_string(:def, :complete_review, @complete_review_template, [:assigns], trim: true) +end diff --git a/lib/hook/outbounds/slack/templates/sync.eex b/lib/hook/outbounds/slack/templates/sync.eex deleted file mode 100644 index 0484d7d3..00000000 --- a/lib/hook/outbounds/slack/templates/sync.eex +++ /dev/null @@ -1,4 +0,0 @@ -*<%= @user.fullname %>* just synced a file: _<%= @document_path %>_ - -*Stats:*<%= for %{"action" => action, "count" => count} <- @stats do %> -<%= action %>: _<%= count %>_<% end %> diff --git a/lib/langue/utils/line_by_line_helper/parser.ex b/lib/langue/utils/line_by_line_helper/parser.ex index afca7418..1967cf37 100644 --- a/lib/langue/utils/line_by_line_helper/parser.ex +++ b/lib/langue/utils/line_by_line_helper/parser.ex @@ -16,6 +16,7 @@ defmodule Langue.Utils.LineByLineHelper.Parser do acc |> Map.put(:line, line) |> Map.put(:captures, Regex.named_captures(prop_line_regex, line)) + |> IO.inspect |> build_entry() |> add_entries() end diff --git a/lib/movement/persisters/base.ex b/lib/movement/persisters/base.ex index fe609a97..ee845892 100644 --- a/lib/movement/persisters/base.ex +++ b/lib/movement/persisters/base.ex @@ -3,6 +3,7 @@ defmodule Movement.Persisters.Base do alias Accent.{Operation, Repo} alias Movement.Mappers.OperationsStats, as: StatMapper + alias Movement.Persisters.ProjectStateChangeWorker alias Movement.Migrator # Inserts operations by batch of 500 to prevent parameters @@ -37,9 +38,21 @@ defmodule Movement.Persisters.Base do end def execute(context) do + project_state_change_context = %{ + project_id: context.assigns[:project] && context.assigns.project.id, + document_id: context.assigns[:document] && context.assigns.document.id, + master_revision_id: context.assigns[:master_revision] && context.assigns.master_revision.id, + revision_id: context.assigns[:revision] && context.assigns.revision.id, + version_id: context.assigns[:version] && context.assigns.version.id, + batch_operation_id: context.assigns[:batch_operation] && context.assigns.batch_operation.id, + user_id: context.assigns[:user_id], + previous_project_state: ProjectStateChangeWorker.get_project_state(context.assigns[:project]) + } + context |> persist_operations() |> migrate_up_operations() + |> tap(fn _ -> Oban.insert(ProjectStateChangeWorker.new(project_state_change_context)) end) end @spec rollback(Movement.Context.t()) :: {Movement.Context.t(), [Operation.t()]} diff --git a/lib/movement/persisters/project_state_change_worker.ex b/lib/movement/persisters/project_state_change_worker.ex new file mode 100644 index 00000000..0ecefc1e --- /dev/null +++ b/lib/movement/persisters/project_state_change_worker.ex @@ -0,0 +1,88 @@ +defmodule Movement.Persisters.ProjectStateChangeWorker do + use Oban.Worker, queue: :hook + + import Ecto.Query + + alias Accent.Hook + alias Accent.Repo + alias Accent.Scopes.Project, as: ProjectScope + + @impl Oban.Worker + def perform(%Oban.Job{args: args}) do + args = cast_args(args) + project_state = get_project_state(args.project) + + if new_conflicts_to_review?(args.previous_project_state, project_state) do + Hook.outbound(%Hook.Context{ + event: "new_conflicts", + project_id: args.project.id, + user_id: args.user.id, + payload: %{ + reviewed_count: project_state.project.reviewed_count, + translations_count: project_state.project.translations_count, + new_conflicts_count: project_state.project.translations_count - args.previous_project_state.project.translations_count + } + }) + end + + if all_reviewed?(args.previous_project_state, project_state) do + Hook.outbound(%Hook.Context{ + event: "complete_review", + project_id: args.project.id, + user_id: args.user.id, + payload: %{ + translations_count: project_state.project.translations_count + } + }) + end + + :ok + end + + defp new_conflicts_to_review?(previous_state, current_state) do + previous_state.project.conflicts_count < current_state.project.conflicts_count + end + + defp all_reviewed?(previous_state, current_state) do + previous_state.project.reviewed_count !== previous_state.project.translations_count and + current_state.project.reviewed_count === current_state.project.translations_count + end + + def get_project_state(nil), do: nil + + def get_project_state(project) do + project = + Accent.Project + |> from(where: [id: ^project.id]) + |> ProjectScope.with_stats() + |> Repo.one() + + %{project: Map.take(project, ~w(translations_count reviewed_count conflicts_count)a)} + end + + defp cast_project_state(args) do + %{ + project: %{ + translations_count: args["project"]["translations_count"], + reviewed_count: args["project"]["reviewed_count"], + conflicts_count: args["project"]["conflicts_count"] + } + } + end + + defp cast_args(args) do + %{ + previous_project_state: cast_project_state(args["previous_project_state"]), + project: get_record(Accent.Project, args["project_id"]), + document: get_record(Accent.Document, args["document_id"]), + master_revision: get_record(Accent.Revision, args["master_revision_id"]), + revision: get_record(Accent.Revision, args["revision_id"]), + version: get_record(Accent.Version, args["version_id"]), + batch_operation: get_record(Accent.Operation, args["batch_operation_id"]), + user: get_record(Accent.User, args["user_id"]) + } + end + + defp get_record(_schema, nil), do: nil + defp get_record(schema, id), do: Repo.get(schema, id) +end diff --git a/lib/web/controllers/merge_controller.ex b/lib/web/controllers/merge_controller.ex index fd9a2c3e..1f954ecb 100644 --- a/lib/web/controllers/merge_controller.ex +++ b/lib/web/controllers/merge_controller.ex @@ -57,7 +57,7 @@ defmodule Accent.MergeController do {:ok, _} -> Accent.Hook.outbound(%HookContext{ - event: "merge", + event: "add_translations", project_id: conn.assigns[:project].id, user_id: conn.assigns[:current_user].id, payload: %{ diff --git a/lib/web/emails/create_comment_email.ex b/lib/web/emails/create_comment_email.ex index b64d15c2..1acfd56d 100644 --- a/lib/web/emails/create_comment_email.ex +++ b/lib/web/emails/create_comment_email.ex @@ -3,12 +3,12 @@ defmodule Accent.CreateCommentEmail do import Accent.EmailViewConfigHelper, only: [mailer_from: 0, x_smtpapi_header: 0] - @spec create(list(String.t()), Accent.Project.t(), map()) :: Bamboo.Email.t() - def create(emails, project, comment) do + @spec create(list(String.t()), Accent.User.t(), Accent.Project.t(), map()) :: Bamboo.Email.t() + def create(emails, user, project, comment) do base_email() |> to(emails) |> mailer_subject(project) - |> assign(:email, comment["user"]["email"]) + |> assign(:user, user) |> assign(:key, comment["translation"]["key"]) |> assign(:text, comment["text"]) |> assign(:translation_path, translation_path(project, comment["translation"]["id"])) diff --git a/lib/web/templates/email/create_comment.html.eex b/lib/web/templates/email/create_comment.html.eex index 85c476b3..caea3901 100644 --- a/lib/web/templates/email/create_comment.html.eex +++ b/lib/web/templates/email/create_comment.html.eex @@ -1,4 +1,4 @@ -

"><%= @email %> has commented on <%= @key %>:

+

"><%= user_display_name(@user) %> has commented on <%= @key %>:

"><%= @text %>

">You can reply here

diff --git a/lib/web/templates/email/create_comment.text.eex b/lib/web/templates/email/create_comment.text.eex index bc1647c5..95660226 100644 --- a/lib/web/templates/email/create_comment.text.eex +++ b/lib/web/templates/email/create_comment.text.eex @@ -1,4 +1,4 @@ -<%= @email %> has commented on <%= @key %>: +<%= user_display_name(@user) %> has commented on <%= @key %>: --- <%= @text %> diff --git a/lib/web/templates/email/project_invite.html.eex b/lib/web/templates/email/project_invite.html.eex index a90b217a..41990ab8 100644 --- a/lib/web/templates/email/project_invite.html.eex +++ b/lib/web/templates/email/project_invite.html.eex @@ -1,4 +1,4 @@ -

"><%= @user.email %> has invited you to collaborate on the project "<%= @project.name %>"

+

"><%= user_display_name(@user) %> has invited you to collaborate on the project "<%= @project.name %>"

">If you already have an account (<%= @email %>), you can just login and you’ll see the project.

diff --git a/lib/web/templates/email/project_invite.text.eex b/lib/web/templates/email/project_invite.text.eex index 1d4871b2..bc5d322a 100644 --- a/lib/web/templates/email/project_invite.text.eex +++ b/lib/web/templates/email/project_invite.text.eex @@ -1,4 +1,4 @@ -<%= @user.email %> has invited you to collaborate on the project "<%= @project.name %>". +<%= user_display_name(@user) %> has invited you to collaborate on the project "<%= @project.name %>". If you already have an account (<%= @email %>), you can just login (<%= webapp_url() %>) and you’ll see the project. diff --git a/lib/web/views/email_view.ex b/lib/web/views/email_view.ex index e80aa725..fc577029 100644 --- a/lib/web/views/email_view.ex +++ b/lib/web/views/email_view.ex @@ -3,4 +3,8 @@ defmodule Accent.EmailView do import Accent.EmailViewStyleHelper import Accent.EmailViewConfigHelper + + def user_display_name(user) do + Accent.User.name_with_fallback(user) + end end diff --git a/test/emails/create_comment_email_test.exs b/test/emails/create_comment_email_test.exs index a4b1f8e2..a82c9e73 100644 --- a/test/emails/create_comment_email_test.exs +++ b/test/emails/create_comment_email_test.exs @@ -11,11 +11,10 @@ defmodule AccentTest.CreateCommentEmail do payload = %{ "text" => comment.text, - "user" => %{"email" => user.email}, "translation" => %{"id" => translation.id, "key" => translation.key} } - email = Accent.CreateCommentEmail.create(emails, project, payload) + email = Accent.CreateCommentEmail.create(emails, user, project, payload) assert email.to == emails assert email.from == {"Accent", "accent-test@example.com"} diff --git a/test/hook/outbounds/discord_test.exs b/test/hook/outbounds/discord_test.exs index 14f6f442..aa0b5efe 100644 --- a/test/hook/outbounds/discord_test.exs +++ b/test/hook/outbounds/discord_test.exs @@ -40,8 +40,8 @@ defmodule AccentTest.Hook.Outbounds.Discord do **Test** just synced a file: *foo.json* **Stats:** - new: *4* - conflict_on_proposed: *10* + New: *4* + Conflict on proposed: *10* """ |> String.trim_trailing() }) diff --git a/test/hook/outbounds/email_test.exs b/test/hook/outbounds/email_test.exs index ba3b8971..30be2319 100644 --- a/test/hook/outbounds/email_test.exs +++ b/test/hook/outbounds/email_test.exs @@ -34,7 +34,6 @@ defmodule AccentTest.Hook.Outbounds.Email do payload = %{ "text" => comment.text, - "user" => %{"email" => user.email}, "translation" => %{"id" => translation.id, "key" => translation.key} } @@ -42,7 +41,7 @@ defmodule AccentTest.Hook.Outbounds.Email do _ = Email.perform(%Oban.Job{args: context}) - refute_delivered_email(CreateCommentEmail.create(["comment@test.com"], project, payload)) + refute_delivered_email(CreateCommentEmail.create(["comment@test.com"], user, project, payload)) end test "comment", %{project: project, translation: translation, user: user} do @@ -52,7 +51,6 @@ defmodule AccentTest.Hook.Outbounds.Email do payload = %{ "text" => comment.text, - "user" => %{"email" => commenter.email}, "translation" => %{"id" => translation.id, "key" => translation.key} } @@ -60,7 +58,7 @@ defmodule AccentTest.Hook.Outbounds.Email do _ = Email.perform(%Oban.Job{args: context}) - assert_delivered_email(CreateCommentEmail.create(["foo@test.com"], project, payload)) + assert_delivered_email(CreateCommentEmail.create(["foo@test.com"], commenter, project, payload)) end test "collaborator", %{project: project, user: user} do diff --git a/test/hook/outbounds/slack_test.exs b/test/hook/outbounds/slack_test.exs index df3743c6..9bcfb470 100644 --- a/test/hook/outbounds/slack_test.exs +++ b/test/hook/outbounds/slack_test.exs @@ -46,8 +46,8 @@ defmodule AccentTest.Hook.Outbounds.Slack do *Test* just synced a file: _foo.json_ *Stats:* - new: _4_ - conflict_on_proposed: _10_ + New: _4_ + Conflict on proposed: _10_ """ |> String.trim_trailing() }) diff --git a/test/movement/persisters/project_state_change_worker_test.exs b/test/movement/persisters/project_state_change_worker_test.exs new file mode 100644 index 00000000..4e469816 --- /dev/null +++ b/test/movement/persisters/project_state_change_worker_test.exs @@ -0,0 +1,120 @@ +defmodule Movement.Persisters.ProjectStateChangeWorkerTest do + use Accent.RepoCase + use Oban.Testing, repo: Accent.Repo + + alias Movement.Persisters.ProjectStateChangeWorker, as: Worker + + alias Accent.{ + Document, + Language, + ProjectCreator, + Repo, + Translation, + User + } + + setup do + user = Repo.insert!(%User{email: "test@test.com"}) + language = Repo.insert!(%Language{name: "English", slug: Ecto.UUID.generate()}) + {:ok, project} = ProjectCreator.create(params: %{main_color: "#f00", name: "My project", language_id: language.id}, user: user) + revision = project |> Repo.preload(:revisions) |> Map.get(:revisions) |> hd() + document = Repo.insert!(%Document{project_id: project.id, path: "test", format: "json"}) + + {:ok, [revision: revision, document: document, project: project, user: user]} + end + + test "noop", %{project: project} do + args = %{ + "project_id" => project.id, + "previous_project_state" => %{ + "project" => %{ + "translations_count" => 0, + "reviewed_count" => 0, + "conflicts_count" => 0 + } + } + } + + Worker.perform(%Oban.Job{args: args}) + + refute_enqueued(worker: Accent.Hook.Outbounds.Mock) + end + + test "new_conflicts", %{user: user, project: project, revision: revision, document: document} do + %Translation{ + key: "a", + proposed_text: "A", + conflicted: true, + corrected_text: "Test", + revision_id: revision.id, + document_id: document.id + } + |> Repo.insert!() + + args = %{ + "project_id" => project.id, + "user_id" => user.id, + "previous_project_state" => %{ + "project" => %{ + "translations_count" => 0, + "reviewed_count" => 0, + "conflicts_count" => 0 + } + } + } + + Worker.perform(%Oban.Job{args: args}) + + assert_enqueued( + worker: Accent.Hook.Outbounds.Mock, + args: %{ + "event" => "new_conflicts", + "payload" => %{ + "reviewed_count" => 0, + "translations_count" => 1, + "new_conflicts_count" => 1 + }, + "project_id" => project.id, + "user_id" => user.id + } + ) + end + + test "complete_review", %{user: user, project: project, revision: revision, document: document} do + %Translation{ + key: "a", + proposed_text: "A", + conflicted: false, + corrected_text: "Test", + revision_id: revision.id, + document_id: document.id + } + |> Repo.insert!() + + args = %{ + "project_id" => project.id, + "user_id" => user.id, + "previous_project_state" => %{ + "project" => %{ + "translations_count" => 1, + "reviewed_count" => 0, + "conflicts_count" => 1 + } + } + } + + Worker.perform(%Oban.Job{args: args}) + + assert_enqueued( + worker: Accent.Hook.Outbounds.Mock, + args: %{ + "event" => "complete_review", + "payload" => %{ + "translations_count" => 1 + }, + "project_id" => project.id, + "user_id" => user.id + } + ) + end +end diff --git a/test/web/controllers/merge_controller_test.exs b/test/web/controllers/merge_controller_test.exs index a234dc64..d095fe20 100644 --- a/test/web/controllers/merge_controller_test.exs +++ b/test/web/controllers/merge_controller_test.exs @@ -51,7 +51,7 @@ defmodule AccentTest.MergeController do assert_enqueued( worker: Accent.Hook.Outbounds.Mock, args: %{ - "event" => "merge", + "event" => "add_translations", "payload" => %{"language_name" => "french", "merge_type" => nil}, "project_id" => project.id, "user_id" => user.id @@ -102,7 +102,7 @@ defmodule AccentTest.MergeController do assert_enqueued( worker: Accent.Hook.Outbounds.Mock, args: %{ - "event" => "merge", + "event" => "add_translations", "payload" => %{"language_name" => "french", "merge_type" => nil}, "project_id" => project.id, "user_id" => user.id @@ -134,7 +134,7 @@ defmodule AccentTest.MergeController do assert_enqueued( worker: Accent.Hook.Outbounds.Mock, args: %{ - "event" => "merge", + "event" => "add_translations", "payload" => %{"language_name" => "french", "merge_type" => nil}, "project_id" => project.id, "user_id" => user.id diff --git a/webapp/app/helpers/array-includes.ts b/webapp/app/helpers/array-includes.ts new file mode 100644 index 00000000..51d3da18 --- /dev/null +++ b/webapp/app/helpers/array-includes.ts @@ -0,0 +1,7 @@ +import {helper} from '@ember/component/helper'; + +const arrayIncludes = ([container, item]: [any[], any]) => { + return container.includes(item); +}; + +export default helper(arrayIncludes); diff --git a/webapp/app/locales/en-us.json b/webapp/app/locales/en-us.json index 30f3d4be..62c5ee96 100644 --- a/webapp/app/locales/en-us.json +++ b/webapp/app/locales/en-us.json @@ -455,7 +455,9 @@ "events": { "title": "Which events would you like to trigger this webhook?", "options": { - "sync": "Sync" + "sync": "Sync with any changes", + "new_conflicts": "New strings to review", + "complete_review": "Project is 100% reviewed" } } } diff --git a/webapp/app/pods/components/project-navigation/list/styles.scss b/webapp/app/pods/components/project-navigation/list/styles.scss index 5e035046..791d1ed6 100644 --- a/webapp/app/pods/components/project-navigation/list/styles.scss +++ b/webapp/app/pods/components/project-navigation/list/styles.scss @@ -39,8 +39,7 @@ left: 1px; transition: 0.2s ease-in-out; transition-property: color, opacity, background; - padding: 4px 12px 3px; - margin-bottom: 6px; + padding: 7px 12px 6px; text-decoration: none; font-weight: 600; font-size: 13px; @@ -56,6 +55,7 @@ .list-item-link-icon { transform: scale(1); + opacity: 1; } .list-item-link-text { @@ -70,6 +70,7 @@ .list-item-link-icon { transform: scale(1); + opacity: 1; } .list-item-link-text { @@ -89,10 +90,11 @@ .list-item-link-icon { transition: 0.2s ease-in-out; - transition-property: fill; + transition-property: fill, opacity; display: inline-block; width: 13px; height: 13px; + opacity: 0.6; } @media (max-width: 800px) { diff --git a/webapp/app/pods/components/project-navigation/list/template.hbs b/webapp/app/pods/components/project-navigation/list/template.hbs index e1df11c6..906ea2e7 100644 --- a/webapp/app/pods/components/project-navigation/list/template.hbs +++ b/webapp/app/pods/components/project-navigation/list/template.hbs @@ -164,18 +164,18 @@ {{/if}} - -
  • - - {{inline-svg "/assets/gear.svg" local-class="list-item-link-icon"}} - - {{t "components.project_navigation.settings_link_title"}} - - -
  • +
  • + + {{inline-svg "/assets/gear.svg" local-class="list-item-link-icon"}} + + {{t "components.project_navigation.settings_link_title"}} + + +
  • + diff --git a/webapp/app/pods/components/project-settings/integrations/form/component.ts b/webapp/app/pods/components/project-settings/integrations/form/component.ts index f87b8ad3..dca257cb 100644 --- a/webapp/app/pods/components/project-settings/integrations/form/component.ts +++ b/webapp/app/pods/components/project-settings/integrations/form/component.ts @@ -54,7 +54,7 @@ export default class IntegrationsForm extends Component { url: string; @tracked - syncChecked: boolean; + events: string[]; @tracked repository: string; @@ -93,10 +93,6 @@ export default class IntegrationsForm extends Component { return `project-settings/integrations/form/${this.service.toLowerCase()}`; } - get events() { - return this.syncChecked ? ['SYNC'] : []; - } - @action didUpdateIntegration() { if (this.args.integration) { @@ -117,8 +113,7 @@ export default class IntegrationsForm extends Component { this.service = this.integration.service || this.services[0]; this.url = this.integration.data.url; - this.syncChecked = - this.integration.events && this.integration.events.includes('SYNC'); + this.events = this.integration.events; this.repository = this.integration.data.repository; this.token = this.integration.data.token; this.defaultRef = this.integration.data.defaultRef; @@ -140,8 +135,8 @@ export default class IntegrationsForm extends Component { } @action - setSyncChecked(syncChecked: boolean) { - this.syncChecked = syncChecked; + setEventsChecked(events: string[]) { + this.events = events; } @action diff --git a/webapp/app/pods/components/project-settings/integrations/form/data-control-checkboxes/component.ts b/webapp/app/pods/components/project-settings/integrations/form/data-control-checkboxes/component.ts index aac84101..23722386 100644 --- a/webapp/app/pods/components/project-settings/integrations/form/data-control-checkboxes/component.ts +++ b/webapp/app/pods/components/project-settings/integrations/form/data-control-checkboxes/component.ts @@ -1,18 +1,36 @@ +import {inject as service} from '@ember/service'; import Component from '@glimmer/component'; import {action} from '@ember/object'; +import {tracked} from '@glimmer/tracking'; +import IntlService from 'ember-intl/services/intl'; interface Args { title: string; - syncChecked: boolean; - syncCheckLabel: string; - onChangeSyncChecked: (checked: boolean) => void; + events: string[]; + onChangeEventsChecked: (events: string[]) => void; } export default class DataControlCheckboxes extends Component { - @action - changeSyncChecked(event: Event) { - const target = event.target as HTMLInputElement; + @service('intl') + intl: IntlService; - this.args.onChangeSyncChecked(target.checked); + allEvents = [ + {value: 'SYNC', label: 'components.project_settings.integrations.events.options.sync'}, + {value: 'NEW_CONFLICTS', label: 'components.project_settings.integrations.events.options.new_conflicts'}, + {value: 'COMPLETE_REVIEW', label: 'components.project_settings.integrations.events.options.complete_review'}, + ]; + + @tracked + selectedEvents: Set = new Set(this.args.events); + + @action + changeEventChecked(event: string) { + if (this.selectedEvents.has(event)) { + this.selectedEvents.delete(event); + } else { + this.selectedEvents.add(event); + } + + this.args.onChangeEventsChecked(Array.from(this.selectedEvents)); } } diff --git a/webapp/app/pods/components/project-settings/integrations/form/data-control-checkboxes/styles.scss b/webapp/app/pods/components/project-settings/integrations/form/data-control-checkboxes/styles.scss index 4283f296..9902a8f6 100644 --- a/webapp/app/pods/components/project-settings/integrations/form/data-control-checkboxes/styles.scss +++ b/webapp/app/pods/components/project-settings/integrations/form/data-control-checkboxes/styles.scss @@ -6,12 +6,21 @@ align-items: center; margin-right: 10px; border: 1px solid var(--background-light-highlight); - padding: 3px 6px; + padding: 4px 6px; border-radius: 3px; - background: var(--background-light); + background: var(--input-background); + cursor: pointer; + transition: 0.2s ease-in-out; + transition-property: background; + + &:hover, + &:focus { + background: var(--background-light); + } input { margin-right: 5px; + cursor: pointer; } } } diff --git a/webapp/app/pods/components/project-settings/integrations/form/data-control-checkboxes/template.hbs b/webapp/app/pods/components/project-settings/integrations/form/data-control-checkboxes/template.hbs index 53c94503..1ffaed31 100644 --- a/webapp/app/pods/components/project-settings/integrations/form/data-control-checkboxes/template.hbs +++ b/webapp/app/pods/components/project-settings/integrations/form/data-control-checkboxes/template.hbs @@ -3,12 +3,14 @@ {{@title}} - + {{#each this.allEvents as |event|}} + + {{/each}} diff --git a/webapp/app/pods/components/project-settings/integrations/form/discord/component.ts b/webapp/app/pods/components/project-settings/integrations/form/discord/component.ts index af913f08..ea5834fb 100644 --- a/webapp/app/pods/components/project-settings/integrations/form/discord/component.ts +++ b/webapp/app/pods/components/project-settings/integrations/form/discord/component.ts @@ -8,9 +8,9 @@ interface Args { errors: any; url: any; project: any; - syncChecked: any; + events: any; onChangeUrl: (url: string) => void; - onChangeSyncChecked: (syncChecked: boolean) => void; + onChangeEventsChecked: (events: string[]) => void; onChangeRepository: (repository: string) => void; onChangeToken: (token: string) => void; onChangeDefaultRef: (defaultRef: string) => void; diff --git a/webapp/app/pods/components/project-settings/integrations/form/discord/template.hbs b/webapp/app/pods/components/project-settings/integrations/form/discord/template.hbs index 23148bc2..9e872380 100644 --- a/webapp/app/pods/components/project-settings/integrations/form/discord/template.hbs +++ b/webapp/app/pods/components/project-settings/integrations/form/discord/template.hbs @@ -10,7 +10,6 @@ diff --git a/webapp/app/pods/components/project-settings/integrations/form/github/component.ts b/webapp/app/pods/components/project-settings/integrations/form/github/component.ts index a9c92080..ec2ec117 100644 --- a/webapp/app/pods/components/project-settings/integrations/form/github/component.ts +++ b/webapp/app/pods/components/project-settings/integrations/form/github/component.ts @@ -10,9 +10,7 @@ interface Args { errors: any; url: any; project: any; - syncChecked: any; onChangeUrl: (url: string) => void; - onChangeSyncChecked: (syncChecked: boolean) => void; onChangeRepository: (repository: string) => void; onChangeToken: (token: string) => void; onChangeDefaultRef: (defaultRef: string) => void; diff --git a/webapp/app/pods/components/project-settings/integrations/form/slack/component.ts b/webapp/app/pods/components/project-settings/integrations/form/slack/component.ts index 432302fe..b951c8a5 100644 --- a/webapp/app/pods/components/project-settings/integrations/form/slack/component.ts +++ b/webapp/app/pods/components/project-settings/integrations/form/slack/component.ts @@ -8,9 +8,9 @@ interface Args { errors: any; url: any; project: any; - syncChecked: any; + events: any; onChangeUrl: (url: string) => void; - onChangeSyncChecked: (syncChecked: boolean) => void; + onChangeEventsChecked: (events: string[]) => void; onChangeRepository: (repository: string) => void; onChangeToken: (token: string) => void; onChangeDefaultRef: (defaultRef: string) => void; diff --git a/webapp/app/pods/components/project-settings/integrations/form/slack/template.hbs b/webapp/app/pods/components/project-settings/integrations/form/slack/template.hbs index 6d36d7e1..e6ac04c8 100644 --- a/webapp/app/pods/components/project-settings/integrations/form/slack/template.hbs +++ b/webapp/app/pods/components/project-settings/integrations/form/slack/template.hbs @@ -10,7 +10,6 @@ diff --git a/webapp/app/pods/components/project-settings/integrations/form/styles.scss b/webapp/app/pods/components/project-settings/integrations/form/styles.scss index 69bc04be..03e18d67 100644 --- a/webapp/app/pods/components/project-settings/integrations/form/styles.scss +++ b/webapp/app/pods/components/project-settings/integrations/form/styles.scss @@ -1,6 +1,7 @@ .project-settings-integrations-form { padding: 10px 15px; border: 1px solid var(--background-light-highlight); + background: var(--background-light); border-radius: 3px; } diff --git a/webapp/app/pods/components/project-settings/integrations/form/template.hbs b/webapp/app/pods/components/project-settings/integrations/form/template.hbs index e9809c8f..8805aefe 100644 --- a/webapp/app/pods/components/project-settings/integrations/form/template.hbs +++ b/webapp/app/pods/components/project-settings/integrations/form/template.hbs @@ -33,9 +33,9 @@ errors=this.errors url=this.url project=@project - syncChecked=this.syncChecked + events=this.events onChangeUrl=(fn this.setUrl) - onChangeSyncChecked=(fn this.setSyncChecked) + onChangeEventsChecked=(fn this.setEventsChecked) onChangeRepository=(fn this.setRepository) onChangeToken=(fn this.setToken) onChangeDefaultRef=(fn this.setDefaultRef) diff --git a/webapp/app/styles/app.scss b/webapp/app/styles/app.scss index 15bb89ef..f58b50d3 100644 --- a/webapp/app/styles/app.scss +++ b/webapp/app/styles/app.scss @@ -167,7 +167,7 @@ html { 'Segoe UI Symbol'; --font-monospace: 'Fira Code', 'Monaco', Courrier, monospace; - --screen-lg: 1300px; + --screen-lg: 1400px; --screen-md: 640px; --screen-sm: 440px; @@ -307,6 +307,7 @@ input[type='radio'] { width: 1rem; height: 1rem; background: var(--body-background); + accent-color: var(--body-background); border: 2px solid var(--color-grey); &:checked { diff --git a/webapp/public/assets/services/github.svg b/webapp/public/assets/services/github.svg index dce76b19..ba46f9dd 100644 --- a/webapp/public/assets/services/github.svg +++ b/webapp/public/assets/services/github.svg @@ -5,6 +5,6 @@ >