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 <hello@guillaumemercier.com>
This commit is contained in:
Simon Prévost 2024-01-21 15:16:52 -05:00 committed by GitHub
parent adccd44a66
commit de6dd818ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1034 additions and 26 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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"},

View File

@ -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"},

View File

@ -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

View File

@ -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) == []

View File

@ -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

View File

@ -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…"
},

View File

@ -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 sest produite lors de la publication sur Azure. Veuillez vérifier vos informations didentification et réessayer.",
"push_button": "Publier",
"sas_base_url": "Base de lURL 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…"
},

View File

@ -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<void>;
}
export default class AzurePushForm extends Component<Args> {
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;
}
}

View File

@ -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;
}

View File

@ -0,0 +1,33 @@
<div {{did-insert this.focusTextarea}} local-class='azure-push-form'>
<h1 local-class='title'>
{{t 'components.project_settings.integrations.execute.azure_storage_container.title'}}
</h1>
{{#if @error}}
<div local-class='errors'>
<div local-class='error'>
{{t 'components.project_settings.integrations.execute.azure_storage_container.error'}}
</div>
</div>
{{/if}}
<div local-class='formItem'>
<ProjectSettings::Integrations::Form::DataControlRadio
@allTargetVersions={{this.allTargetVersions}}
@targetVersion={{this.targetVersion}}
@specificVersion={{this.specificVersion}}
@onChangeTargetVersion={{this.setTargetVersion}}
@onChangeSpecificVersion={{this.setSpecificVersion}}
/>
</div>
<div local-class='formActions'>
<LinkTo @route='logged-in.project.edit.service-integrations' @model={{@project.id}} class='button button--filled button--white'>
{{t 'components.project_settings.integrations.execute.azure_storage_container.cancel_button'}}
</LinkTo>
<AsyncButton class='button button--filled' @loading={{this.isSubmitting}} @onClick={{this.submit}}>
{{t 'components.project_settings.integrations.execute.azure_storage_container.push_button'}}
</AsyncButton>
</div>
</div>

View File

@ -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<Args> {
@action
changeSas(event: Event) {
const target = event.target as HTMLInputElement;
this.args.onChangeSas(target.value);
}
}

View File

@ -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;
}

View File

@ -0,0 +1,8 @@
<ProjectSettings::Integrations::Form::DataControlText
@error={{field-error @errors 'data.azureStorageContainerSas'}}
@label={{t 'components.project_settings.integrations.data.azure_storage_container_sas'}}
@helpLinkTitle='How to create a SAS URL?'
@helpLinkHref='https://learn.microsoft.com/en-us/rest/api/storageservices/delegate-access-with-shared-access-signature'
@placeholder='https://<account-name>.blob.core.windows.net/<container-name>/?<SAS-token>'
@onChange={{this.changeSas}}
/>

View File

@ -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<Args> {
@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<Args> {
data: {
url: this.url,
repository: this.repository,
token: this.token,
defaultRef: this.defaultRef,
},
};
@ -115,8 +122,8 @@ export default class IntegrationsForm extends Component<Args> {
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<Args> {
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<Args> {
repository: this.repository,
token: this.token,
defaultRef: this.defaultRef,
azureStorageContainerSas: this.azureStorageContainerSas,
},
});

View File

@ -25,10 +25,6 @@
margin-right: 5px;
}
.actions {
text-align: right;
}
.readonly-service {
display: flex;
align-items: center;

View File

@ -34,6 +34,7 @@
onChangeRepository=(fn this.setRepository)
onChangeToken=(fn this.setToken)
onChangeDefaultRef=(fn this.setDefaultRef)
onChangeSas=(fn this.setAzureStorageContainerSas)
}}
</div>

View File

@ -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<string, true>;
@ -26,6 +29,13 @@ export default class IntegrationsListItem extends Component<Args> {
@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<Args> {
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 = [];

View File

@ -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<Args> {
@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;
}
}

View File

@ -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;
}

View File

@ -0,0 +1,46 @@
<div local-class='azure-push-form'>
<h1 local-class='title'>
{{t 'components.project_settings.integrations.execute.azure_storage_container.title'}}
</h1>
<div local-class='info'>
<div>
<strong>{{t 'components.project_settings.integrations.execute.azure_storage_container.sas_base_url'}}</strong>
<span>{{@integration.data.sasBaseUrl}}</span>
</div>
</div>
{{#if this.error}}
<div local-class='errors'>
<div local-class='error'>
{{t 'components.project_settings.integrations.execute.azure_storage_container.error'}}
</div>
</div>
{{/if}}
<div local-class='formItem'>
<div local-class='data-control'>
<h3 local-class='data-title'>
{{t 'components.project_settings.integrations.execute.azure_storage_container.target_version.label'}}
</h3>
{{#each this.allTargetVersions as |target|}}
<label local-class='radio'>
<input type='radio' checked={{eq this.targetVersion target.value}} name='target_version' {{on 'change' (fn this.setTargetVersion target.value)}} required />
{{t target.label}}
</label>
{{/each}}
{{#if (eq this.targetVersion 'SPECIFIC')}}
<ProjectSettings::Integrations::Form::DataControlText @placeholder='1.0.0' @value={{this.tag}} @onChange={{this.setTag}} />
{{/if}}
</div>
</div>
<div local-class='formActions'>
<AsyncButton class='button button--filled' @loading={{this.isSubmitting}} @onClick={{this.submit}}>
{{inline-svg '/assets/arrow-up-right.svg' class='button-icon'}}
{{t 'components.project_settings.integrations.execute.azure_storage_container.push_button'}}
</AsyncButton>
</div>
</div>

View File

@ -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;
}

View File

@ -18,10 +18,21 @@
<span local-class='details-preview'>
{{@integration.data.url}}
{{@integration.data.repository}}
{{@integration.data.sasBaseUrl}}
{{#if @integration.lastExecutedAt}}
<span local-class='details-last-executed-at'>{{t 'components.project_settings.integrations.last_executed_at'}} {{time-ago-in-words @integration.lastExecutedAt}}</span>
{{/if}}
</span>
</div>
<div local-class='details-actions'>
{{#if this.serviceIsExecutable}}
<button class='button button--filled button--iconOnly' {{on 'click' (fn this.toggleExecuting)}}>
{{inline-svg '/assets/arrow-up-right.svg' class='button-icon'}}
</button>
{{/if}}
{{#if (get @permissions 'update_project_integration')}}
<button class='button button--filled button--white' {{on 'click' (fn this.toggleEdit)}}>
{{t 'components.project_settings.integrations.edit'}}
@ -36,4 +47,10 @@
</div>
</div>
{{/if}}
</li>
</li>
{{#if this.isExecuting}}
<AccModal @small={{true}} @onClose={{fn this.toggleExecuting}}>
{{component this.dataExecuteComponent integration=@integration close=this.toggleExecuting}}
</AccModal>
{{/if}}

View File

@ -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
}
}
}
`;

View File

@ -27,14 +27,21 @@ export default gql`
}
}
... on ProjectIntegrationGitHub {
... on ProjectIntegrationGithub {
data {
id
repository
token
defaultRef
}
}
... on ProjectIntegrationAzureStorageContainer {
lastExecutedAt
data {
id
sasBaseUrl
}
}
}
}
}

View File

@ -25,9 +25,10 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({
kind: 'INTERFACE',
name: 'ProjectIntegration',
possibleTypes: [
{name: 'ProjectIntegrationAzureStorageContainer'},
{name: 'ProjectIntegrationDiscord'},
{name: 'ProjectIntegrationSlack'},
{name: 'ProjectIntegrationGitHub'},
{name: 'ProjectIntegrationGithub'},
],
},
],

View File

@ -0,0 +1,12 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="7" y1="17" x2="17" y2="7"></line>
<polyline points="7 7 17 7 17 17"></polyline>
</svg>

After

Width:  |  Height:  |  Size: 275 B

View File

@ -0,0 +1,64 @@
<svg
width="20"
height="20"
viewBox="0 0 96 96"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<linearGradient
id="e399c19f-b68f-429d-b176-18c2117ff73c"
x1="-1032.172"
x2="-1059.213"
y1="145.312"
y2="65.426"
gradientTransform="matrix(1 0 0 -1 1075 158)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stop-color="#114a8b" />
<stop offset="1" stop-color="#0669bc" />
</linearGradient>
<linearGradient
id="ac2a6fc2-ca48-4327-9a3c-d4dcc3256e15"
x1="-1023.725"
x2="-1029.98"
y1="108.083"
y2="105.968"
gradientTransform="matrix(1 0 0 -1 1075 158)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stop-opacity=".3" />
<stop offset=".071" stop-opacity=".2" />
<stop offset=".321" stop-opacity=".1" />
<stop offset=".623" stop-opacity=".05" />
<stop offset="1" stop-opacity="0" />
</linearGradient>
<linearGradient
id="a7fee970-a784-4bb1-af8d-63d18e5f7db9"
x1="-1027.165"
x2="-997.482"
y1="147.642"
y2="68.561"
gradientTransform="matrix(1 0 0 -1 1075 158)"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stop-color="#3ccbf4" />
<stop offset="1" stop-color="#2892df" />
</linearGradient>
</defs>
<path
fill="url(#e399c19f-b68f-429d-b176-18c2117ff73c)"
d="M33.338 6.544h26.038l-27.03 80.087a4.152 4.152 0 0 1-3.933 2.824H8.149a4.145 4.145 0 0 1-3.928-5.47L29.404 9.368a4.152 4.152 0 0 1 3.934-2.825z"
/>
<path
fill="#0078d4"
d="M71.175 60.261h-41.29a1.911 1.911 0 0 0-1.305 3.309l26.532 24.764a4.171 4.171 0 0 0 2.846 1.121h23.38z"
/>
<path
fill="url(#ac2a6fc2-ca48-4327-9a3c-d4dcc3256e15)"
d="M33.338 6.544a4.118 4.118 0 0 0-3.943 2.879L4.252 83.917a4.14 4.14 0 0 0 3.908 5.538h20.787a4.443 4.443 0 0 0 3.41-2.9l5.014-14.777 17.91 16.705a4.237 4.237 0 0 0 2.666.972H81.24L71.024 60.261l-29.781.007L59.47 6.544z"
/>
<path
fill="url(#a7fee970-a784-4bb1-af8d-63d18e5f7db9)"
d="M66.595 9.364a4.145 4.145 0 0 0-3.928-2.82H33.648a4.146 4.146 0 0 1 3.928 2.82l25.184 74.62a4.146 4.146 0 0 1-3.928 5.472h29.02a4.146 4.146 0 0 0 3.927-5.472z"
/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB