From de6dd818eeda6a6abd30083cd9d4ff453249c4a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pr=C3=A9vost?= Date: Sun, 21 Jan 2024 15:16:52 -0500 Subject: [PATCH] Add Azure Storage Container integration with new execution pattern (#402) * Account Name working E2E. * Account key and container name now functional too * Form updated to only display bucket configuration * Functional UI * WIP * Adding and editing an integration fully works now! * Try to make build pass * Adjust test * Use mutation correctly in frontend * Refactor integration execution to be less tied to 'push strings to Azure' * Add execute azure storage container to upload files * Add tests --------- Co-authored-by: Guillaume Mercier --- lib/accent/auth/role_abilities.ex | 1 + .../execute/azure_storage_container.ex | 95 ++++++++++++++ .../integrations/integration_manager.ex | 40 +++++- lib/accent/schemas/document_format.ex | 16 ++- lib/accent/schemas/integration.ex | 3 + lib/graphql/mutations/integration.ex | 19 +++ lib/graphql/resolvers/integration.ex | 7 ++ lib/graphql/types/integration.ex | 28 ++++- mix.exs | 2 +- mix.lock | 2 +- ...032_add_last_executed_for_integrations.exs | 11 ++ test/graphql/resolvers/integration_test.exs | 3 +- test/services/integration_manager_test.exs | 116 ++++++++++++++++++ webapp/app/locales/en-us.json | 21 +++- webapp/app/locales/fr-ca.json | 21 +++- .../components/azure-push-form/component.ts | 67 ++++++++++ .../components/azure-push-form/styles.scss | 50 ++++++++ .../components/azure-push-form/template.hbs | 33 +++++ .../form/azure-storage-container/component.ts | 17 +++ .../form/azure-storage-container/styles.scss | 60 +++++++++ .../form/azure-storage-container/template.hbs | 8 ++ .../integrations/form/component.ts | 21 +++- .../integrations/form/styles.scss | 4 - .../integrations/form/template.hbs | 1 + .../integrations/list/item/component.ts | 20 +++ .../azure-storage-container/component.ts | 105 ++++++++++++++++ .../azure-storage-container/styles.scss | 103 ++++++++++++++++ .../azure-storage-container/template.hbs | 46 +++++++ .../integrations/list/item/styles.scss | 8 ++ .../integrations/list/item/template.hbs | 19 ++- webapp/app/queries/execute-integration.ts | 23 ++++ .../queries/project-service-integrations.ts | 11 +- webapp/app/services/apollo.ts | 3 +- webapp/public/assets/arrow-up-right.svg | 12 ++ webapp/public/assets/services/azure.svg | 64 ++++++++++ 35 files changed, 1034 insertions(+), 26 deletions(-) create mode 100644 lib/accent/integrations/execute/azure_storage_container.ex create mode 100644 priv/repo/migrations/20240119142032_add_last_executed_for_integrations.exs create mode 100644 test/services/integration_manager_test.exs create mode 100644 webapp/app/pods/components/azure-push-form/component.ts create mode 100644 webapp/app/pods/components/azure-push-form/styles.scss create mode 100644 webapp/app/pods/components/azure-push-form/template.hbs create mode 100644 webapp/app/pods/components/project-settings/integrations/form/azure-storage-container/component.ts create mode 100644 webapp/app/pods/components/project-settings/integrations/form/azure-storage-container/styles.scss create mode 100644 webapp/app/pods/components/project-settings/integrations/form/azure-storage-container/template.hbs create mode 100644 webapp/app/pods/components/project-settings/integrations/list/item/execute/azure-storage-container/component.ts create mode 100644 webapp/app/pods/components/project-settings/integrations/list/item/execute/azure-storage-container/styles.scss create mode 100644 webapp/app/pods/components/project-settings/integrations/list/item/execute/azure-storage-container/template.hbs create mode 100644 webapp/app/queries/execute-integration.ts create mode 100644 webapp/public/assets/arrow-up-right.svg create mode 100644 webapp/public/assets/services/azure.svg diff --git a/lib/accent/auth/role_abilities.ex b/lib/accent/auth/role_abilities.ex index 47d05c64..b1501254 100644 --- a/lib/accent/auth/role_abilities.ex +++ b/lib/accent/auth/role_abilities.ex @@ -71,6 +71,7 @@ defmodule Accent.RoleAbilities do revoke_project_api_token index_project_integrations create_project_integration + execute_project_integration update_project_integration delete_project_integration create_version diff --git a/lib/accent/integrations/execute/azure_storage_container.ex b/lib/accent/integrations/execute/azure_storage_container.ex new file mode 100644 index 00000000..067087e5 --- /dev/null +++ b/lib/accent/integrations/execute/azure_storage_container.ex @@ -0,0 +1,95 @@ +defmodule Accent.IntegrationManager.Execute.AzureStorageContainer do + @moduledoc false + + alias Accent.Document + alias Accent.Repo + alias Accent.Revision + alias Accent.Scopes.Document, as: DocumentScope + alias Accent.Scopes.Revision, as: RevisionScope + alias Accent.Scopes.Translation, as: TranslationScope + alias Accent.Scopes.Version, as: VersionScope + alias Accent.Translation + alias Accent.Version + + def upload_translations(integration, params) do + project = Repo.one!(Ecto.assoc(integration, :project)) + version = fetch_version(project, params) + documents = fetch_documents(project) + revisions = fetch_revisions(project) + master_revision = Repo.preload(Repo.one!(RevisionScope.master(Ecto.assoc(project, :revisions))), :language) + + uploads = + Enum.flat_map(documents, fn document -> + Enum.flat_map(revisions, fn revision -> + translations = fetch_translations(document, revision, version) + + if Enum.any?(translations) do + render_options = %{ + translations: translations, + master_language: Revision.language(master_revision), + language: Revision.language(revision), + document: document + } + + %{render: render} = Accent.TranslationsRenderer.render_translations(render_options) + [%{document: %{document | render: render}, language: Accent.Revision.language(revision)}] + else + [] + end + end) + end) + + for upload <- uploads do + file = Path.join([System.tmp_dir(), Accent.Utils.SecureRandom.urlsafe_base64(16)]) + :ok = File.write(file, upload.document.render) + + uri = URI.parse(integration.data.azure_storage_container_sas) + extension = Accent.DocumentFormat.extension_by_format(upload.document.format) + + path = + Path.join([ + uri.path, + (version && version.tag) || "latest", + upload.language.slug, + upload.document.path <> "." <> extension + ]) + + HTTPoison.put(URI.to_string(%{uri | path: path}), {:file, file}, [{"x-ms-blob-type", "BlockBlob"}]) + end + + :ok + end + + defp fetch_version(project, %{target_version: :specific, tag: tag}) do + Version + |> VersionScope.from_project(project.id) + |> VersionScope.from_tag(tag) + |> Repo.one() + end + + defp fetch_version(_, _) do + nil + end + + defp fetch_documents(project) do + Document + |> DocumentScope.from_project(project.id) + |> Repo.all() + end + + defp fetch_revisions(project) do + Revision + |> RevisionScope.from_project(project.id) + |> Repo.all() + |> Repo.preload(:language) + end + + defp fetch_translations(document, revision, version) do + Translation + |> TranslationScope.active() + |> TranslationScope.from_document(document.id) + |> TranslationScope.from_revision(revision.id) + |> TranslationScope.from_version(version && version.id) + |> Repo.all() + end +end diff --git a/lib/accent/integrations/integration_manager.ex b/lib/accent/integrations/integration_manager.ex index f2edc7d2..971757c9 100644 --- a/lib/accent/integrations/integration_manager.ex +++ b/lib/accent/integrations/integration_manager.ex @@ -4,6 +4,7 @@ defmodule Accent.IntegrationManager do alias Accent.Integration alias Accent.Repo + alias Accent.User @spec create(map()) :: {:ok, Integration.t()} | {:error, Ecto.Changeset.t()} def create(params) do @@ -20,6 +21,22 @@ defmodule Accent.IntegrationManager do |> Repo.update() end + @spec execute(Integration.t(), User.t(), map()) :: {:ok, Integration.t()} + def execute(integration, user, params) do + case execute_integration(integration, params) do + :ok -> + integration + |> change(%{last_executed_at: DateTime.utc_now(), last_executed_by_user_id: user.id}) + |> force_change(:updated_at, integration.updated_at) + |> Repo.update!() + + _ -> + :ok + end + + {:ok, integration} + end + @spec delete(Integration.t()) :: {:ok, Integration.t()} | {:error, Ecto.Changeset.t()} def delete(integration) do Repo.delete(integration) @@ -28,12 +45,25 @@ defmodule Accent.IntegrationManager do defp changeset(model, params) do model |> cast(params, [:project_id, :user_id, :service, :events]) - |> validate_inclusion(:service, ~w(slack github discord)) + |> validate_inclusion(:service, ~w(slack github discord azure_storage_container)) |> cast_embed(:data, with: changeset_data(params[:service] || model.service)) |> foreign_key_constraint(:project_id) |> validate_required([:service, :data]) end + defp execute_integration(%{service: "azure_storage_container"} = integration, params) do + Accent.IntegrationManager.Execute.AzureStorageContainer.upload_translations( + integration, + params[:azure_storage_container] + ) + + :ok + end + + defp execute_integration(_integration, _params) do + :noop + end + defp changeset_data("slack") do fn model, params -> model @@ -58,6 +88,14 @@ defmodule Accent.IntegrationManager do end end + defp changeset_data("azure_storage_container") do + fn model, params -> + model + |> cast(params, [:azure_storage_container_sas]) + |> validate_required([:azure_storage_container_sas]) + end + end + defp changeset_data(_) do fn model, params -> cast(model, params, []) end end diff --git a/lib/accent/schemas/document_format.ex b/lib/accent/schemas/document_format.ex index ec155ff1..8a712a05 100644 --- a/lib/accent/schemas/document_format.ex +++ b/lib/accent/schemas/document_format.ex @@ -2,9 +2,15 @@ defmodule Accent.DocumentFormat do @moduledoc false defmacro ids, do: Enum.map(Langue.modules(), & &1.id) - def all, - do: - Enum.map(Langue.modules(), fn module -> - %{name: module.display_name(), slug: module.id(), extension: module.extension()} - end) + def all do + Enum.map(Langue.modules(), fn module -> + %{name: module.display_name(), slug: module.id(), extension: module.extension()} + end) + end + + def extension_by_format(slug) do + Enum.find_value(Langue.modules(), fn module -> + module.id() === slug && module.extension() + end) + end end diff --git a/lib/accent/schemas/integration.ex b/lib/accent/schemas/integration.ex index 84a65765..28b08712 100644 --- a/lib/accent/schemas/integration.ex +++ b/lib/accent/schemas/integration.ex @@ -3,6 +3,7 @@ defmodule Accent.Integration do use Accent.Schema schema "integrations" do + field(:last_executed_at, :utc_datetime_usec) field(:service, :string) field(:events, {:array, :string}) @@ -11,10 +12,12 @@ defmodule Accent.Integration do field(:repository) field(:token) field(:default_ref) + field(:azure_storage_container_sas) end belongs_to(:project, Accent.Project) belongs_to(:user, Accent.User) + belongs_to(:last_executed_by_user, Accent.User) timestamps() end diff --git a/lib/graphql/mutations/integration.ex b/lib/graphql/mutations/integration.ex index b63d7807..7d0483b5 100644 --- a/lib/graphql/mutations/integration.ex +++ b/lib/graphql/mutations/integration.ex @@ -7,12 +7,23 @@ defmodule Accent.GraphQL.Mutations.Integration do alias Accent.GraphQL.Resolvers.Integration, as: IntegrationResolver + enum :project_integration_execute_azure_storage_container_target_version do + value(:specific) + value(:latest) + end + + input_object :project_integration_execute_azure_storage_container_input do + field(:target_version, :project_integration_execute_azure_storage_container_target_version) + field(:tag, :string) + end + input_object :project_integration_data_input do field(:id, :id) field(:url, :string) field(:repository, :string) field(:default_ref, :string) field(:token, :string) + field(:azure_storage_container_sas, :string) end payload_object(:project_integration_payload, :project_integration) @@ -28,6 +39,14 @@ defmodule Accent.GraphQL.Mutations.Integration do middleware(&build_payload/2) end + field :execute_project_integration, :project_integration_payload do + arg(:id, non_null(:id)) + arg(:azure_storage_container, :project_integration_execute_azure_storage_container_input) + + resolve(integration_authorize(:execute_project_integration, &IntegrationResolver.execute/3)) + middleware(&build_payload/2) + end + field :update_project_integration, :project_integration_payload do arg(:id, non_null(:id)) arg(:service, non_null(:project_integration_service)) diff --git a/lib/graphql/resolvers/integration.ex b/lib/graphql/resolvers/integration.ex index 8b1b4032..f946fa10 100644 --- a/lib/graphql/resolvers/integration.ex +++ b/lib/graphql/resolvers/integration.ex @@ -25,6 +25,13 @@ defmodule Accent.GraphQL.Resolvers.Integration do |> build() end + @spec execute(Integration.t(), map(), GraphQLContext.t()) :: integration_operation + def execute(integration, args, info) do + integration + |> IntegrationManager.execute(info.context[:conn].assigns[:current_user], args) + |> build() + end + @spec delete(Integration.t(), map(), GraphQLContext.t()) :: integration_operation def delete(integration, _args, _info) do integration diff --git a/lib/graphql/types/integration.ex b/lib/graphql/types/integration.ex index a032a65d..8d3cb9b3 100644 --- a/lib/graphql/types/integration.ex +++ b/lib/graphql/types/integration.ex @@ -6,6 +6,7 @@ defmodule Accent.GraphQL.Types.Integration do value(:slack, as: "slack") value(:discord, as: "discord") value(:github, as: "github") + value(:azure_storage_container, as: "azure_storage_container") end enum :project_integration_event do @@ -23,7 +24,8 @@ defmodule Accent.GraphQL.Types.Integration do resolve_type(fn %{service: "discord"}, _ -> :project_integration_discord %{service: "slack"}, _ -> :project_integration_slack - %{service: "github"}, _ -> :project_integration_git_hub + %{service: "github"}, _ -> :project_integration_github + %{service: "azure_storage_container"}, _ -> :project_integration_azure_storage_container end) end @@ -45,7 +47,7 @@ defmodule Accent.GraphQL.Types.Integration do interfaces([:project_integration]) end - object :project_integration_git_hub do + object :project_integration_github do field(:id, non_null(:id)) field(:service, non_null(:project_integration_service)) field(:events, non_null(list_of(non_null(:project_integration_event)))) @@ -54,6 +56,15 @@ defmodule Accent.GraphQL.Types.Integration do interfaces([:project_integration]) end + object :project_integration_azure_storage_container do + field(:id, non_null(:id)) + field(:service, non_null(:project_integration_service)) + field(:last_executed_at, :datetime) + field(:data, non_null(:project_integration_azure_data)) + + interfaces([:project_integration]) + end + object :project_integration_slack_data do field(:id, non_null(:id)) field(:url, non_null(:string)) @@ -62,7 +73,18 @@ defmodule Accent.GraphQL.Types.Integration do object :project_integration_github_data do field(:id, non_null(:id)) field(:repository, non_null(:string)) - field(:token, non_null(:string)) field(:default_ref, non_null(:string)) end + + object :project_integration_azure_data do + field(:id, non_null(:id)) + + field(:sas_base_url, non_null(:string), + resolve: fn data, _, _ -> + uri = URI.parse(data.azure_storage_container_sas) + uri = URI.to_string(%{uri | query: nil}) + {:ok, uri} + end + ) + end end diff --git a/mix.exs b/mix.exs index 25766368..e2af9e49 100644 --- a/mix.exs +++ b/mix.exs @@ -43,7 +43,7 @@ defmodule Accent.Mixfile do # Plugs {:plug_assign, "~> 1.0.0"}, {:canada, "~> 2.0.0", override: true}, - {:canary, "~> 1.1.0"}, + {:canary, github: "runhyve/canary"}, {:corsica, "~> 2.0"}, {:bandit, "~> 1.0"}, {:plug, "1.14.2"}, diff --git a/mix.lock b/mix.lock index e47f8818..93f785a1 100644 --- a/mix.lock +++ b/mix.lock @@ -9,7 +9,7 @@ "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, "cachex": {:hex, :cachex, "3.6.0", "14a1bfbeee060dd9bec25a5b6f4e4691e3670ebda28c8ba2884b12fe30b36bf8", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "ebf24e373883bc8e0c8d894a63bbe102ae13d918f790121f5cfe6e485cc8e2e2"}, "canada": {:hex, :canada, "2.0.0", "ce5e058f576a0625959fc5427fcde15311fb28a5ebc13775eafd13468ad16553", [:mix], [], "hexpm", "49a648c48d8b0864380f38f02a7f316bd30fd45602205c48197432b5225d8596"}, - "canary": {:hex, :canary, "1.1.1", "4138d5e05db8497c477e4af73902eb9ae06e49dceaa13c2dd9f0b55525ded48b", [:mix], [{:canada, "~> 1.0.1", [hex: :canada, repo: "hexpm", optional: false]}, {:ecto, ">= 1.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f348d9848693c830a65b707bba9e4dfdd6434e8c356a8d4477e4535afb0d653b"}, + "canary": {:git, "https://github.com/runhyve/canary.git", "b81d780e1cb7a1c276599f980ab9c9a7c9cd8c12", []}, "castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "cloak": {:hex, :cloak, "1.1.2", "7e0006c2b0b98d976d4f559080fabefd81f0e0a50a3c4b621f85ceeb563e80bb", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "940d5ac4fcd51b252930fd112e319ea5ae6ab540b722f3ca60a85666759b9585"}, diff --git a/priv/repo/migrations/20240119142032_add_last_executed_for_integrations.exs b/priv/repo/migrations/20240119142032_add_last_executed_for_integrations.exs new file mode 100644 index 00000000..0ec13115 --- /dev/null +++ b/priv/repo/migrations/20240119142032_add_last_executed_for_integrations.exs @@ -0,0 +1,11 @@ +defmodule Accent.Repo.Migrations.AddLastExecutedForIntegrations do + @moduledoc false + use Ecto.Migration + + def change do + alter table(:integrations) do + add(:last_executed_at, :utc_datetime_usec) + add(:last_executed_by_user_id, references(:users, type: :uuid)) + end + end +end diff --git a/test/graphql/resolvers/integration_test.exs b/test/graphql/resolvers/integration_test.exs index 17993685..ea9a0f8e 100644 --- a/test/graphql/resolvers/integration_test.exs +++ b/test/graphql/resolvers/integration_test.exs @@ -109,7 +109,8 @@ defmodule AccentTest.GraphQL.Resolvers.Integration do {:ok, integration} = Resolver.create(project, %{service: "foo", data: %{url: ""}}, context) assert integration.errors == [ - service: {"is invalid", [validation: :inclusion, enum: ["slack", "github", "discord"]]} + service: + {"is invalid", [validation: :inclusion, enum: ["slack", "github", "discord", "azure_storage_container"]]} ] assert Repo.all(Integration) == [] diff --git a/test/services/integration_manager_test.exs b/test/services/integration_manager_test.exs new file mode 100644 index 00000000..2f3f18b6 --- /dev/null +++ b/test/services/integration_manager_test.exs @@ -0,0 +1,116 @@ +defmodule AccentTest.IntegrationManager do + @moduledoc false + use Accent.RepoCase, async: true + + import Mock + + alias Accent.Document + alias Accent.Integration + alias Accent.IntegrationManager + alias Accent.Language + alias Accent.Project + alias Accent.Repo + alias Accent.Revision + alias Accent.Translation + alias Accent.User + alias Accent.Version + + describe "execute" do + setup do + project = Repo.insert!(%Project{main_color: "red", name: "com"}) + user = Repo.insert!(%User{email: "test@test.com"}) + language = Repo.insert!(%Language{slug: "fr-custom", name: "Fr"}) + revision = Repo.insert!(%Revision{project: project, language: language}) + document = Repo.insert!(%Document{project: project, path: "foo", format: "gettext"}) + + {:ok, [project: project, user: user, language: language, revision: revision, document: document]} + end + + test "azure storage container with version", %{ + user: user, + revision: revision, + document: document, + project: project + } do + version = Repo.insert!(%Version{project: project, tag: "1.2.45", name: "vNext", user: user}) + + Repo.insert!(%Translation{ + revision: revision, + document: document, + key: "key", + corrected_text: "value latest" + }) + + Repo.insert!(%Translation{ + revision: revision, + version: version, + document: document, + key: "key", + corrected_text: "value v1.2.45" + }) + + integration = + Repo.insert!(%Integration{ + project: project, + user: user, + service: "azure_storage_container", + data: %{azure_storage_container_sas: "http://azure.blob.test/container?sas=1234"} + }) + + with_mock HTTPoison, + put: fn url, {:file, file}, headers -> + content = File.read!(file) + + assert content === """ + msgid "key" + msgstr "value v1.2.45" + """ + + assert String.ends_with?(url, "1.2.45/fr-custom/foo.po?sas=1234") + assert headers === [{"x-ms-blob-type", "BlockBlob"}] + {:ok, nil} + end do + IntegrationManager.execute(integration, user, %{ + azure_storage_container: %{target_version: :specific, tag: "1.2.45"} + }) + end + + updated_integration = Repo.reload!(integration) + + assert updated_integration.last_executed_at + assert updated_integration.last_executed_by_user_id === user.id + end + + test "azure storage container latest version", %{ + user: user, + revision: revision, + document: document, + project: project + } do + Repo.insert!(%Translation{revision: revision, document: document, key: "key", corrected_text: "value"}) + + integration = + Repo.insert!(%Integration{ + project: project, + user: user, + service: "azure_storage_container", + data: %{azure_storage_container_sas: "http://azure.blob.test/container?sas=1234"} + }) + + with_mock HTTPoison, + put: fn url, body, headers -> + assert match?({:file, _}, body) + assert String.ends_with?(url, "latest/fr-custom/foo.po?sas=1234") + assert headers === [{"x-ms-blob-type", "BlockBlob"}] + {:ok, nil} + end do + IntegrationManager.execute(integration, user, %{azure_storage_container: %{target_version: :latest}}) + end + + updated_integration = Repo.reload!(integration) + + assert updated_integration.last_executed_at + assert updated_integration.last_executed_by_user_id === user.id + end + end +end diff --git a/webapp/app/locales/en-us.json b/webapp/app/locales/en-us.json index e7e38d7f..a4f40461 100644 --- a/webapp/app/locales/en-us.json +++ b/webapp/app/locales/en-us.json @@ -491,7 +491,9 @@ "cancel": "Cancel", "edit": "Edit", "delete": "Delete", + "last_executed_at": "Last executed at:", "data": { + "azure_storage_container_sas": "SAS URL", "default_ref": "Default ref", "repository": "Repository", "token": "Token", @@ -510,6 +512,23 @@ "new_conflicts": "New strings to review", "complete_review": "Project is 100% reviewed" } + }, + "execute": { + "azure_storage_container": { + "title": "Upload files to Azure Storage Container", + "cancel_button": "Cancel", + "error": "Error while pushing to Azure. Check your credentials and try again.", + "push_button": "Upload", + "sas_base_url": "Base upload URL", + "submit_confirm": "Uploading files to Azure Storage Container can override existing files if you upload for the latest or an already uploaded version tag.", + "target_version": { + "label": "Target Version", + "options": { + "latest": "Latest Version", + "specific": "Specific Version" + } + } + } } } }, @@ -959,7 +978,7 @@ "DISCORD": "Discord", "GITHUB": "GitHub", "SLACK": "Slack", - "MICROSOFT": "Microsoft" + "AZURE_STORAGE_CONTAINER": "Azure Storage Container" }, "search_input_placeholder_text": "Search for a string…" }, diff --git a/webapp/app/locales/fr-ca.json b/webapp/app/locales/fr-ca.json index a83d8225..93ee7f7a 100644 --- a/webapp/app/locales/fr-ca.json +++ b/webapp/app/locales/fr-ca.json @@ -506,8 +506,10 @@ "save": "Sauver", "cancel": "Annuler", "edit": "Éditer", + "last_executed_at": "Dernière exécution:", "delete": "Supprimer", "data": { + "azure_storage_container_sas": "SAS URL", "default_ref": "Réf par défaut", "repository": "Dépôt", "token": "Jeton", @@ -526,6 +528,23 @@ "new_conflicts": "Nouvelles chaîne à réviser", "complete_review": "Le projet est revu à 100%" } + }, + "execute": { + "azure_storage_container": { + "title": "Publier sur Azure Storage Container", + "cancel_button": "Annuler", + "error": "Une erreur s’est produite lors de la publication sur Azure. Veuillez vérifier vos informations d’identification et réessayer.", + "push_button": "Publier", + "sas_base_url": "Base de l’URL de publication", + "submit_confirm": "La publication de fichiers vers Azure Storage Container peut remplacer les fichiers existants si vous publiez la dernière version ou une version déjà publiée.", + "target_version": { + "label": "Version Cible", + "options": { + "latest": "Dernière version", + "specific": "Version spécifique" + } + } + } } } }, @@ -959,7 +978,7 @@ "DISCORD": "Discord", "GITHUB": "GitHub", "SLACK": "Slack", - "MICROSOFT": "Microsoft" + "AZURE_STORAGE_CONTAINER": "Azure Storage Container" }, "search_input_placeholder_text": "Rechercher une chaîne…" }, diff --git a/webapp/app/pods/components/azure-push-form/component.ts b/webapp/app/pods/components/azure-push-form/component.ts new file mode 100644 index 00000000..ef532f5a --- /dev/null +++ b/webapp/app/pods/components/azure-push-form/component.ts @@ -0,0 +1,67 @@ +import Component from '@glimmer/component'; +import {tracked} from '@glimmer/tracking'; +import {action} from '@ember/object'; + +interface Args { + error: boolean; + project: any; + onPush: ({ + targetVersion, + specificVersion, + }: { + targetVersion: string; + specificVersion: string | null; + }) => Promise; +} + +export default class AzurePushForm extends Component { + allTargetVersions = [ + { + value: 'LATEST', + label: + 'components.project_settings.integrations.target_version.options.latest', + }, + { + value: 'SPECIFIC', + label: + 'components.project_settings.integrations.target_version.options.specific', + }, + { + value: 'ALL', + label: + 'components.project_settings.integrations.target_version.options.all', + }, + ]; + + @tracked + targetVersion = this.allTargetVersions[0].value; + + @tracked + specificVersion: string | null; + + @tracked + isSubmitting = false; + + @action + async submit() { + this.isSubmitting = true; + + await this.args.onPush({ + targetVersion: this.targetVersion, + specificVersion: this.specificVersion, + }); + + this.isSubmitting = false; + } + + @action + setTargetVersion(targetVersion: string) { + this.targetVersion = targetVersion; + } + @action + setSpecificVersion(event: Event) { + const target = event.target as HTMLInputElement; + + this.specificVersion = target.value; + } +} diff --git a/webapp/app/pods/components/azure-push-form/styles.scss b/webapp/app/pods/components/azure-push-form/styles.scss new file mode 100644 index 00000000..23a5a892 --- /dev/null +++ b/webapp/app/pods/components/azure-push-form/styles.scss @@ -0,0 +1,50 @@ +.azure-push-form { + padding: 20px; + background: var(--content-background); +} + +.title { + margin-bottom: 20px; + text-align: center; + font-size: 27px; + font-weight: 300; + color: var(--color-primary); + line-height: 1.2; +} + +.text { + font-size: 13px; + margin-bottom: 20px; + color: #555; +} + +.textInput { + @extend %textInput; + flex-grow: 1; + flex-shrink: 1; + padding: 10px; + min-width: 250px; + width: 100%; + font-size: 12px; + font-family: var(--font-primary); +} + +.errors { + margin-bottom: 15px; + padding-bottom: 5px; +} + +.error { + margin-bottom: 5px; + color: var(--color-error); + font-size: 13px; + font-weight: bold; +} + +.formItem { + margin-bottom: 20px; +} + +.formActions { + padding-top: 10px; +} diff --git a/webapp/app/pods/components/azure-push-form/template.hbs b/webapp/app/pods/components/azure-push-form/template.hbs new file mode 100644 index 00000000..402f86fc --- /dev/null +++ b/webapp/app/pods/components/azure-push-form/template.hbs @@ -0,0 +1,33 @@ +
+

+ {{t 'components.project_settings.integrations.execute.azure_storage_container.title'}} +

+ + {{#if @error}} +
+
+ {{t 'components.project_settings.integrations.execute.azure_storage_container.error'}} +
+
+ {{/if}} + +
+ +
+ +
+ + {{t 'components.project_settings.integrations.execute.azure_storage_container.cancel_button'}} + + + + {{t 'components.project_settings.integrations.execute.azure_storage_container.push_button'}} + +
+
\ No newline at end of file diff --git a/webapp/app/pods/components/project-settings/integrations/form/azure-storage-container/component.ts b/webapp/app/pods/components/project-settings/integrations/form/azure-storage-container/component.ts new file mode 100644 index 00000000..1f9c098b --- /dev/null +++ b/webapp/app/pods/components/project-settings/integrations/form/azure-storage-container/component.ts @@ -0,0 +1,17 @@ +import Component from '@glimmer/component'; +import {action} from '@ember/object'; + +interface Args { + errors: any; + project: any; + onChangeSas: (url: string) => void; +} + +export default class AzureStorageContainer extends Component { + @action + changeSas(event: Event) { + const target = event.target as HTMLInputElement; + + this.args.onChangeSas(target.value); + } +} diff --git a/webapp/app/pods/components/project-settings/integrations/form/azure-storage-container/styles.scss b/webapp/app/pods/components/project-settings/integrations/form/azure-storage-container/styles.scss new file mode 100644 index 00000000..7ad947c9 --- /dev/null +++ b/webapp/app/pods/components/project-settings/integrations/form/azure-storage-container/styles.scss @@ -0,0 +1,60 @@ +.instructions { + border-top: 1px solid var(--background-light-highlight); + padding-top: 10px; + margin: 20px 0 10px; +} + +.instructions-text { + margin-top: 7px; + font-style: italic; + color: #555; + + a { + font-family: var(--font-monospace); + color: var(--color-primary); + text-decoration: none; + + &:focus, + &:hover { + text-decoration: underline; + opacity: 0.8; + } + } +} + +.data-control { + margin-bottom: 15px; +} + +.data-title { + display: block; + margin-bottom: 5px; + font-size: 13px; + font-weight: bold; +} + +.data-title-help { + margin-left: 4px; + font-size: 11px; + font-weight: normal; + text-decoration: none; + color: var(--color-primary); + + &:focus, + &:hover { + text-decoration: underline; + } +} + +.textInput { + @extend %textInput; + flex-grow: 1; + flex-shrink: 0; + padding: 8px 10px; + margin-right: 10px; + background: #fafafa; + max-width: 500px; + width: 100%; + font-family: var(--font-monospace); + font-size: 11px; +} diff --git a/webapp/app/pods/components/project-settings/integrations/form/azure-storage-container/template.hbs b/webapp/app/pods/components/project-settings/integrations/form/azure-storage-container/template.hbs new file mode 100644 index 00000000..f974c326 --- /dev/null +++ b/webapp/app/pods/components/project-settings/integrations/form/azure-storage-container/template.hbs @@ -0,0 +1,8 @@ + \ No newline at end of file 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 dca257cb..86aed6f7 100644 --- a/webapp/app/pods/components/project-settings/integrations/form/component.ts +++ b/webapp/app/pods/components/project-settings/integrations/form/component.ts @@ -6,6 +6,7 @@ import IntlService from 'ember-intl/services/intl'; import {tracked} from '@glimmer/tracking'; const LOGOS = { + AZURE_STORAGE_CONTAINER: 'assets/services/azure.svg', DISCORD: 'assets/services/discord.svg', GITHUB: 'assets/services/github.svg', SLACK: 'assets/services/slack.svg', @@ -17,7 +18,7 @@ interface Args { service, events, integration, - data: {url, repository, token, defaultRef}, + data: {url, repository, token, defaultRef, azureStorageContainerSas}, }: { service: any; events: any; @@ -27,6 +28,7 @@ interface Args { repository: string; token: string; defaultRef: string; + azureStorageContainerSas: string; }; }) => Promise<{errors: any}>; onCancel: () => void; @@ -62,10 +64,16 @@ export default class IntegrationsForm extends Component { @tracked token: string; + @tracked + azureStorageContainerSas: string; + + @tracked + azureStorageContainerSasBaseUrl: string; + @tracked defaultRef = 'main'; - services = ['SLACK', 'GITHUB', 'DISCORD']; + services = ['AZURE_STORAGE_CONTAINER', 'SLACK', 'GITHUB', 'DISCORD']; @not('url') emptyUrl: boolean; @@ -105,7 +113,6 @@ export default class IntegrationsForm extends Component { data: { url: this.url, repository: this.repository, - token: this.token, defaultRef: this.defaultRef, }, }; @@ -115,8 +122,8 @@ export default class IntegrationsForm extends Component { this.url = this.integration.data.url; this.events = this.integration.events; this.repository = this.integration.data.repository; - this.token = this.integration.data.token; this.defaultRef = this.integration.data.defaultRef; + this.azureStorageContainerSasBaseUrl = this.integration.data.sasBaseUrl; } @action @@ -154,6 +161,11 @@ export default class IntegrationsForm extends Component { this.defaultRef = defaultRef; } + @action + setAzureStorageContainerSas(sas: string) { + this.azureStorageContainerSas = sas; + } + @action async submit() { this.isSubmiting = true; @@ -167,6 +179,7 @@ export default class IntegrationsForm extends Component { repository: this.repository, token: this.token, defaultRef: this.defaultRef, + azureStorageContainerSas: this.azureStorageContainerSas, }, }); 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 8a905815..cb5405c9 100644 --- a/webapp/app/pods/components/project-settings/integrations/form/styles.scss +++ b/webapp/app/pods/components/project-settings/integrations/form/styles.scss @@ -25,10 +25,6 @@ margin-right: 5px; } -.actions { - text-align: right; -} - .readonly-service { display: flex; align-items: center; 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 e727bf7d..6039a418 100644 --- a/webapp/app/pods/components/project-settings/integrations/form/template.hbs +++ b/webapp/app/pods/components/project-settings/integrations/form/template.hbs @@ -34,6 +34,7 @@ onChangeRepository=(fn this.setRepository) onChangeToken=(fn this.setToken) onChangeDefaultRef=(fn this.setDefaultRef) + onChangeSas=(fn this.setAzureStorageContainerSas) }} diff --git a/webapp/app/pods/components/project-settings/integrations/list/item/component.ts b/webapp/app/pods/components/project-settings/integrations/list/item/component.ts index 84363e67..fc34b6be 100644 --- a/webapp/app/pods/components/project-settings/integrations/list/item/component.ts +++ b/webapp/app/pods/components/project-settings/integrations/list/item/component.ts @@ -3,11 +3,14 @@ import Component from '@glimmer/component'; import {tracked} from '@glimmer/tracking'; const LOGOS = { + AZURE_STORAGE_CONTAINER: 'assets/services/azure.svg', DISCORD: 'assets/services/discord.svg', GITHUB: 'assets/services/github.svg', SLACK: 'assets/services/slack.svg', }; +const EXECUTABLE_SERVICES = ['AZURE_STORAGE_CONTAINER']; + interface Args { project: any; permissions: Record; @@ -26,6 +29,13 @@ export default class IntegrationsListItem extends Component { @tracked isDeleting = false; + @tracked + isExecuting = false; + + get serviceIsExecutable() { + return EXECUTABLE_SERVICES.includes(this.args.integration.service); + } + get logoService() { const service: keyof typeof LOGOS = this.args.integration.service; @@ -36,6 +46,16 @@ export default class IntegrationsListItem extends Component { return `general.integration_services.${this.args.integration.service}`; } + get dataExecuteComponent() { + return `project-settings/integrations/list/item/execute/${this.args.integration.service.toLowerCase()}`; + } + + @action + toggleExecuting() { + this.errors = []; + this.isExecuting = !this.isExecuting; + } + @action toggleEdit() { this.errors = []; diff --git a/webapp/app/pods/components/project-settings/integrations/list/item/execute/azure-storage-container/component.ts b/webapp/app/pods/components/project-settings/integrations/list/item/execute/azure-storage-container/component.ts new file mode 100644 index 00000000..209facb9 --- /dev/null +++ b/webapp/app/pods/components/project-settings/integrations/list/item/execute/azure-storage-container/component.ts @@ -0,0 +1,105 @@ +import {inject as service} from '@ember/service'; +import {action} from '@ember/object'; +import {readOnly} from '@ember/object/computed'; +import {tracked} from '@glimmer/tracking'; +import Component from '@glimmer/component'; +import IntlService from 'ember-intl/services/intl'; +import FlashMessages from 'ember-cli-flash/services/flash-messages'; +import ApolloMutate from 'accent-webapp/services/apollo-mutate'; +import executeIntegration from 'accent-webapp/queries/execute-integration'; + +const FLASH_MESSAGE_CREATE_SUCCESS = + 'pods.versions.new.flash_messages.create_success'; +const FLASH_MESSAGE_CREATE_ERROR = + 'pods.versions.new.flash_messages.create_error'; + +interface Args { + close: () => void; + integration: { + id: string; + }; +} + +export default class IntegrationExecuteAzureStorageContainer extends Component { + @service('intl') + intl: IntlService; + + @service('apollo-mutate') + apolloMutate: ApolloMutate; + + @service('flash-messages') + flashMessages: FlashMessages; + + @readOnly('model.projectModel.project') + project: any; + + @tracked + error = false; + + allTargetVersions = [ + { + value: 'LATEST', + label: + 'components.project_settings.integrations.execute.azure_storage_container.target_version.options.latest', + }, + { + value: 'SPECIFIC', + label: + 'components.project_settings.integrations.execute.azure_storage_container.target_version.options.specific', + }, + ]; + + @tracked + targetVersion = this.allTargetVersions[0].value; + + @tracked + tag: string | null = null; + + @tracked + isSubmitting = false; + + @action + setTargetVersion(targetVersion: string) { + this.tag = null; + this.targetVersion = targetVersion; + } + + @action + setTag(event: Event) { + const target = event.target as HTMLInputElement; + + this.tag = target.value; + } + + @action + async submit() { + const confirmMessage = this.intl.t( + 'components.project_settings.integrations.execute.azure_storage_container.submit_confirm' + ); + /* eslint-disable-next-line no-alert */ + if (!window.confirm(confirmMessage)) { + return; + } + + const response = await this.apolloMutate.mutate({ + mutation: executeIntegration, + refetchQueries: ['ProjectServiceIntegrations'], + variables: { + integrationId: this.args.integration.id, + azureStorageContainer: { + tag: this.tag, + targetVersion: this.targetVersion, + }, + }, + }); + + if (response.errors) { + this.flashMessages.error(this.intl.t(FLASH_MESSAGE_CREATE_ERROR)); + } else { + this.args.close(); + this.flashMessages.success(this.intl.t(FLASH_MESSAGE_CREATE_SUCCESS)); + } + + return response; + } +} diff --git a/webapp/app/pods/components/project-settings/integrations/list/item/execute/azure-storage-container/styles.scss b/webapp/app/pods/components/project-settings/integrations/list/item/execute/azure-storage-container/styles.scss new file mode 100644 index 00000000..458474bf --- /dev/null +++ b/webapp/app/pods/components/project-settings/integrations/list/item/execute/azure-storage-container/styles.scss @@ -0,0 +1,103 @@ +.azure-push-form { + padding: 20px; + background: var(--content-background); +} + +.title { + margin-bottom: 20px; + text-align: center; + line-height: 1.2; + font-size: 27px; + font-weight: 300; + color: var(--color-primary); +} + +.info { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 10px; + padding-bottom: 16px; + border-bottom: 1px solid var(--background-light-highlight); + font-size: 13px; + + div { + display: flex; + flex-direction: column; + gap: 2px; + } + + span { + font-size: 12px; + font-family: var(--font-monospace); + opacity: 0.6; + } +} + +.text { + font-size: 13px; + margin-bottom: 20px; + color: #555; +} + +.textInput { + @extend %textInput; + flex-grow: 1; + flex-shrink: 1; + padding: 10px; + min-width: 250px; + width: 100%; + font-size: 12px; + font-family: var(--font-primary); +} + +.errors { + margin-bottom: 15px; + padding-bottom: 5px; +} + +.error { + margin-bottom: 5px; + color: var(--color-error); + font-size: 13px; + font-weight: bold; +} + +.formActions { + display: flex; + justify-content: flex-end; + padding-top: 20px; +} + +.data-control { + .radio { + display: inline-flex; + align-items: center; + margin-right: 10px; + border: 1px solid var(--background-light-highlight); + padding: 4px 6px; + border-radius: var(--border-radius); + background: var(--input-background); + cursor: pointer; + font-size: 12px; + transition: 0.2s ease-in-out; + transition-property: background; + + &:hover, + &:focus { + background: var(--background-light); + } + + input { + margin-right: 5px; + cursor: pointer; + } + } +} + +.data-title { + display: block; + margin-bottom: 5px; + font-size: 13px; + font-weight: bold; +} diff --git a/webapp/app/pods/components/project-settings/integrations/list/item/execute/azure-storage-container/template.hbs b/webapp/app/pods/components/project-settings/integrations/list/item/execute/azure-storage-container/template.hbs new file mode 100644 index 00000000..aaee9024 --- /dev/null +++ b/webapp/app/pods/components/project-settings/integrations/list/item/execute/azure-storage-container/template.hbs @@ -0,0 +1,46 @@ +
+

+ {{t 'components.project_settings.integrations.execute.azure_storage_container.title'}} +

+ +
+
+ {{t 'components.project_settings.integrations.execute.azure_storage_container.sas_base_url'}} + {{@integration.data.sasBaseUrl}} +
+
+ + {{#if this.error}} +
+
+ {{t 'components.project_settings.integrations.execute.azure_storage_container.error'}} +
+
+ {{/if}} + +
+
+

+ {{t 'components.project_settings.integrations.execute.azure_storage_container.target_version.label'}} +

+ + {{#each this.allTargetVersions as |target|}} + + {{/each}} + + {{#if (eq this.targetVersion 'SPECIFIC')}} + + {{/if}} +
+
+ +
+ + {{inline-svg '/assets/arrow-up-right.svg' class='button-icon'}} + {{t 'components.project_settings.integrations.execute.azure_storage_container.push_button'}} + +
+
\ No newline at end of file diff --git a/webapp/app/pods/components/project-settings/integrations/list/item/styles.scss b/webapp/app/pods/components/project-settings/integrations/list/item/styles.scss index b1051345..f86b185a 100644 --- a/webapp/app/pods/components/project-settings/integrations/list/item/styles.scss +++ b/webapp/app/pods/components/project-settings/integrations/list/item/styles.scss @@ -33,6 +33,12 @@ color: var(--color-black); } +.details-last-executed-at { + margin-left: 5px; + font-size: 11px; + opacity: 0.4; +} + .details-logo { flex: 0 0 20px; margin-right: 8px; @@ -40,5 +46,7 @@ } .details-actions { + display: flex; + gap: 10px; flex: 0 0 auto; } diff --git a/webapp/app/pods/components/project-settings/integrations/list/item/template.hbs b/webapp/app/pods/components/project-settings/integrations/list/item/template.hbs index a83d25db..a37ac1ad 100644 --- a/webapp/app/pods/components/project-settings/integrations/list/item/template.hbs +++ b/webapp/app/pods/components/project-settings/integrations/list/item/template.hbs @@ -18,10 +18,21 @@ {{@integration.data.url}} {{@integration.data.repository}} + {{@integration.data.sasBaseUrl}} + + {{#if @integration.lastExecutedAt}} + {{t 'components.project_settings.integrations.last_executed_at'}} {{time-ago-in-words @integration.lastExecutedAt}} + {{/if}}
+ {{#if this.serviceIsExecutable}} + + {{/if}} + {{#if (get @permissions 'update_project_integration')}}
{{/if}} - \ No newline at end of file + + +{{#if this.isExecuting}} + + {{component this.dataExecuteComponent integration=@integration close=this.toggleExecuting}} + +{{/if}} \ No newline at end of file diff --git a/webapp/app/queries/execute-integration.ts b/webapp/app/queries/execute-integration.ts new file mode 100644 index 00000000..50475481 --- /dev/null +++ b/webapp/app/queries/execute-integration.ts @@ -0,0 +1,23 @@ +import gql from 'graphql-tag'; + +export default gql` + mutation IntegrationExecute( + $integrationId: ID! + $azureStorageContainer: ProjectIntegrationExecuteAzureStorageContainerInput + ) { + executeProjectIntegration( + id: $integrationId + azureStorageContainer: $azureStorageContainer + ) { + projectIntegration: result { + id + } + + successful + errors: messages { + code + field + } + } + } +`; diff --git a/webapp/app/queries/project-service-integrations.ts b/webapp/app/queries/project-service-integrations.ts index 3e171756..cf2045cb 100644 --- a/webapp/app/queries/project-service-integrations.ts +++ b/webapp/app/queries/project-service-integrations.ts @@ -27,14 +27,21 @@ export default gql` } } - ... on ProjectIntegrationGitHub { + ... on ProjectIntegrationGithub { data { id repository - token defaultRef } } + + ... on ProjectIntegrationAzureStorageContainer { + lastExecutedAt + data { + id + sasBaseUrl + } + } } } } diff --git a/webapp/app/services/apollo.ts b/webapp/app/services/apollo.ts index e1b5b26a..ea660305 100644 --- a/webapp/app/services/apollo.ts +++ b/webapp/app/services/apollo.ts @@ -25,9 +25,10 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({ kind: 'INTERFACE', name: 'ProjectIntegration', possibleTypes: [ + {name: 'ProjectIntegrationAzureStorageContainer'}, {name: 'ProjectIntegrationDiscord'}, {name: 'ProjectIntegrationSlack'}, - {name: 'ProjectIntegrationGitHub'}, + {name: 'ProjectIntegrationGithub'}, ], }, ], diff --git a/webapp/public/assets/arrow-up-right.svg b/webapp/public/assets/arrow-up-right.svg new file mode 100644 index 00000000..3149451e --- /dev/null +++ b/webapp/public/assets/arrow-up-right.svg @@ -0,0 +1,12 @@ + + + + diff --git a/webapp/public/assets/services/azure.svg b/webapp/public/assets/services/azure.svg new file mode 100644 index 00000000..2b7675ed --- /dev/null +++ b/webapp/public/assets/services/azure.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + +