Add languages to admin settings
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
parent
17786b025a
commit
586d8c440d
|
@ -18,6 +18,7 @@ config :mobilizon, :instance,
|
|||
hostname: "localhost",
|
||||
registrations_open: false,
|
||||
registration_email_allowlist: [],
|
||||
languages: [],
|
||||
demo: false,
|
||||
repository: Mix.Project.config()[:source_url],
|
||||
allow_relay: true,
|
||||
|
|
|
@ -104,6 +104,15 @@ export const REJECT_RELAY = gql`
|
|||
${RELAY_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const LANGUAGES = gql`
|
||||
query {
|
||||
languages {
|
||||
code
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ADMIN_SETTINGS_FRAGMENT = gql`
|
||||
fragment adminSettingsFragment on AdminSettings {
|
||||
instanceName
|
||||
|
@ -118,6 +127,7 @@ export const ADMIN_SETTINGS_FRAGMENT = gql`
|
|||
instancePrivacyPolicyUrl
|
||||
instanceRules
|
||||
registrationsOpen
|
||||
instanceLanguages
|
||||
}
|
||||
`;
|
||||
|
||||
|
@ -144,6 +154,7 @@ export const SAVE_ADMIN_SETTINGS = gql`
|
|||
$instancePrivacyPolicyUrl: String
|
||||
$instanceRules: String
|
||||
$registrationsOpen: Boolean
|
||||
$instanceLanguages: [String]
|
||||
) {
|
||||
saveAdminSettings(
|
||||
instanceName: $instanceName
|
||||
|
@ -158,6 +169,7 @@ export const SAVE_ADMIN_SETTINGS = gql`
|
|||
instancePrivacyPolicyUrl: $instancePrivacyPolicyUrl
|
||||
instanceRules: $instanceRules
|
||||
registrationsOpen: $registrationsOpen
|
||||
instanceLanguages: $instanceLanguages
|
||||
) {
|
||||
...adminSettingsFragment
|
||||
}
|
||||
|
|
|
@ -789,5 +789,9 @@
|
|||
"Congratulations, your account is now created!": "Congratulations, your account is now created!",
|
||||
"Now, create your first profile:": "Now, create your first profile:",
|
||||
"If you have opted for manual validation of participants, Mobilizon will send you an email to inform you of new participations to be processed. You can choose the frequency of these notifications below.": "If you have opted for manual validation of participants, Mobilizon will send you an email to inform you of new participations to be processed. You can choose the frequency of these notifications below.",
|
||||
"You will be able to add an avatar and set other options in your account settings.": "You will be able to add an avatar and set other options in your account settings."
|
||||
"You will be able to add an avatar and set other options in your account settings.": "You will be able to add an avatar and set other options in your account settings.",
|
||||
"Instance languages": "Instance languages",
|
||||
"Main languages you/your moderators speak": "Main languages you/your moderators speak",
|
||||
"Select languages": "Select languages",
|
||||
"No languages found": "No languages found"
|
||||
}
|
||||
|
|
|
@ -388,7 +388,7 @@
|
|||
"My events": "Mes évènements",
|
||||
"My groups": "Mes groupes",
|
||||
"My identities": "Mes identités",
|
||||
"NOTE! The default terms have not been checked over by a lawyer and thus are unlikely to provide full legal protection for all situations for an instance admin using them. They are also not specific to all countries and jurisdictions. If you are unsure, please check with a lawyer.": "REMARQUE : les conditions par défaut n'ont pas été vérifiées par un juriste et ne sont donc susceptibles de ne pas offrir une protection juridique complète dans toutes les situations pour un·e administrateur·ice d'instance qui les utilise. Elles ne sont pas non plus spécifiques à tous les pays et juridictions. Si vous n'êtes pas sûr, veuillez consulter un juriste.",
|
||||
"NOTE! The default terms have not been checked over by a lawyer and thus are unlikely to provide full legal protection for all situations for an instance admin using them. They are also not specific to all countries and jurisdictions. If you are unsure, please check with a lawyer.": "REMARQUE : les conditions par défaut n'ont pas été vérifiées par un·e juriste et ne sont donc susceptibles de ne pas offrir une protection juridique complète dans toutes les situations pour un·e administrateur·ice d'instance qui les utilise. Elles ne sont pas non plus spécifiques à tous les pays et juridictions. Si vous n'êtes pas sûr, veuillez consulter un·e juriste.",
|
||||
"Name": "Nom",
|
||||
"New discussion": "Nouvelle discussion",
|
||||
"New email": "Nouvelle adresse e-mail",
|
||||
|
@ -831,5 +831,9 @@
|
|||
"Congratulations, your account is now created!": "Bravo, votre compte est dorénavant créé !",
|
||||
"Now, create your first profile:": "Maintenant, créez votre premier profil :",
|
||||
"If you have opted for manual validation of participants, Mobilizon will send you an email to inform you of new participations to be processed. You can choose the frequency of these notifications below.": "Si vous avez opté pour la validation manuelle des participantes, Mobilizon vous enverra un email pour vous informer des nouvelles participations à traiter. Vous pouvez choisir la fréquence de ces notifications ci-dessous.",
|
||||
"You will be able to add an avatar and set other options in your account settings.": "Vous pourrez ajouter un avatar et définir d'autres options dans les paramètres de votre compte."
|
||||
"You will be able to add an avatar and set other options in your account settings.": "Vous pourrez ajouter un avatar et définir d'autres options dans les paramètres de votre compte.",
|
||||
"Instance languages": "Langues de l'instance",
|
||||
"Main languages you/your moderators speak": "Principales langues parlées par vous / vos modérateurs",
|
||||
"Select languages": "Choisissez une langue",
|
||||
"No languages found": "Aucune langue trouvée"
|
||||
}
|
||||
|
|
|
@ -20,6 +20,11 @@ export enum InstancePrivacyType {
|
|||
CUSTOM = "CUSTOM",
|
||||
}
|
||||
|
||||
export interface ILanguage {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface IAdminSettings {
|
||||
instanceName: string;
|
||||
instanceDescription: string;
|
||||
|
@ -33,4 +38,5 @@ export interface IAdminSettings {
|
|||
instancePrivacyPolicyUrl: string | null;
|
||||
instanceRules: string;
|
||||
registrationsOpen: boolean;
|
||||
instanceLanguages: string[];
|
||||
}
|
||||
|
|
|
@ -43,6 +43,24 @@
|
|||
<p class="content" v-else>{{ $t("Registration is closed.") }}</p>
|
||||
</b-switch>
|
||||
</b-field>
|
||||
<div class="field">
|
||||
<label class="label has-help">{{ $t("Instance languages") }}</label>
|
||||
<small>
|
||||
{{ $t("Main languages you/your moderators speak") }}
|
||||
</small>
|
||||
<b-taginput
|
||||
v-model="adminSettings.instanceLanguages"
|
||||
:data="filteredLanguages"
|
||||
autocomplete
|
||||
:open-on-focus="true"
|
||||
field="name"
|
||||
icon="label"
|
||||
:placeholder="$t('Select languages')"
|
||||
@typing="getFilteredLanguages"
|
||||
>
|
||||
<template slot="empty">{{ $t("No languages found") }}</template>
|
||||
</b-taginput>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label has-help">{{ $t("Instance Long Description") }}</label>
|
||||
<small>
|
||||
|
@ -256,32 +274,55 @@
|
|||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-property-decorator";
|
||||
import { ADMIN_SETTINGS, SAVE_ADMIN_SETTINGS } from "@/graphql/admin";
|
||||
import { IAdminSettings, InstanceTermsType, InstancePrivacyType } from "../../types/admin.model";
|
||||
import { Component, Vue, Watch } from "vue-property-decorator";
|
||||
import { ADMIN_SETTINGS, SAVE_ADMIN_SETTINGS, LANGUAGES } from "@/graphql/admin";
|
||||
import {
|
||||
IAdminSettings,
|
||||
InstanceTermsType,
|
||||
InstancePrivacyType,
|
||||
ILanguage,
|
||||
} from "../../types/admin.model";
|
||||
import RouteName from "../../router/name";
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
adminSettings: ADMIN_SETTINGS,
|
||||
languages: LANGUAGES,
|
||||
},
|
||||
})
|
||||
export default class Settings extends Vue {
|
||||
adminSettings!: IAdminSettings;
|
||||
|
||||
languages!: ILanguage[];
|
||||
|
||||
filteredLanguages: string[] = [];
|
||||
|
||||
InstanceTermsType = InstanceTermsType;
|
||||
|
||||
InstancePrivacyType = InstancePrivacyType;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
async updateSettings() {
|
||||
@Watch("languages")
|
||||
setCorrectLanguagesNames(): void {
|
||||
if (this.languages && this.adminSettings) {
|
||||
this.adminSettings.instanceLanguages = this.adminSettings.instanceLanguages
|
||||
.map((code) => this.languageForCode(code))
|
||||
.filter((language) => language) as string[];
|
||||
}
|
||||
}
|
||||
|
||||
async updateSettings(): Promise<void> {
|
||||
const variables = { ...this.adminSettings };
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
variables.instanceLanguages = variables.instanceLanguages.map((language) => {
|
||||
return this.codeForLanguage(language);
|
||||
});
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: SAVE_ADMIN_SETTINGS,
|
||||
variables: {
|
||||
...this.adminSettings,
|
||||
},
|
||||
variables,
|
||||
});
|
||||
this.$notifier.success(this.$t("Admin settings successfully saved.") as string);
|
||||
} catch (e) {
|
||||
|
@ -289,6 +330,32 @@ export default class Settings extends Vue {
|
|||
this.$notifier.error(this.$t("Failed to save admin settings") as string);
|
||||
}
|
||||
}
|
||||
|
||||
getFilteredLanguages(text: string): void {
|
||||
this.filteredLanguages = this.languages
|
||||
? this.languages
|
||||
.filter((language: ILanguage) => {
|
||||
return language.name.toString().toLowerCase().indexOf(text.toLowerCase()) >= 0;
|
||||
})
|
||||
.map(({ name }) => name)
|
||||
: [];
|
||||
}
|
||||
|
||||
codeForLanguage(language: string): string | undefined {
|
||||
if (this.languages) {
|
||||
const lang = this.languages.find(({ name }) => name === language);
|
||||
if (lang) return lang.code;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
languageForCode(codeGiven: string): string | undefined {
|
||||
if (this.languages) {
|
||||
const lang = this.languages.find(({ code }) => code === codeGiven);
|
||||
if (lang) return lang.name;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -8,6 +8,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
|
|||
alias Mobilizon.{Actors, Admin, Config, Events}
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Admin.{ActionLog, Setting}
|
||||
alias Mobilizon.Cldr.Language
|
||||
alias Mobilizon.Config
|
||||
alias Mobilizon.Discussions.Comment
|
||||
alias Mobilizon.Events.Event
|
||||
|
@ -156,6 +157,19 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
|
|||
end
|
||||
end
|
||||
|
||||
def get_list_of_languages(_parent, _args, _resolution) do
|
||||
locale = Gettext.get_locale()
|
||||
|
||||
case Language.known_languages(locale) do
|
||||
data when is_map(data) ->
|
||||
data = Enum.map(data, fn {code, elem} -> %{code: code, name: elem.standard} end)
|
||||
{:ok, data}
|
||||
|
||||
{:error, err} ->
|
||||
{:error, err}
|
||||
end
|
||||
end
|
||||
|
||||
def get_dashboard(_parent, _args, %{context: %{current_user: %User{role: role}}})
|
||||
when is_admin(role) do
|
||||
last_public_event_published =
|
||||
|
@ -202,11 +216,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
|
|||
res <-
|
||||
res
|
||||
|> Enum.map(fn {key, %Setting{value: value}} ->
|
||||
case value do
|
||||
"true" -> {key, true}
|
||||
"false" -> {key, false}
|
||||
value -> {key, value}
|
||||
end
|
||||
{key, Admin.get_setting_value(value)}
|
||||
end)
|
||||
|> Enum.into(%{}),
|
||||
:ok <- eventually_update_instance_actor(res) do
|
||||
|
|
|
@ -75,6 +75,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
|
|||
demo_mode: Config.instance_demo_mode?(),
|
||||
description: Config.instance_description(),
|
||||
long_description: Config.instance_long_description(),
|
||||
languages: Config.instance_languages(),
|
||||
anonymous: %{
|
||||
participation: %{
|
||||
allowed: Config.anonymous_participation?(),
|
||||
|
|
|
@ -64,6 +64,11 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
|
|||
end)
|
||||
end
|
||||
|
||||
object :language do
|
||||
field(:code, :string, description: "The iso-639-3 language code")
|
||||
field(:name, :string, description: "The language name")
|
||||
end
|
||||
|
||||
object :dashboard do
|
||||
field(:last_public_event_published, :event, description: "Last public event publish")
|
||||
field(:number_of_users, :integer, description: "The number of local users")
|
||||
|
@ -85,6 +90,7 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
|
|||
field(:instance_privacy_policy_url, :string)
|
||||
field(:instance_rules, :string)
|
||||
field(:registrations_open, :boolean)
|
||||
field(:instance_languages, list_of(:string))
|
||||
end
|
||||
|
||||
enum :instance_terms_type do
|
||||
|
@ -107,6 +113,10 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
|
|||
resolve(&Admin.list_action_logs/3)
|
||||
end
|
||||
|
||||
field :languages, type: list_of(:language) do
|
||||
resolve(&Admin.get_list_of_languages/3)
|
||||
end
|
||||
|
||||
field :dashboard, type: :dashboard do
|
||||
resolve(&Admin.get_dashboard/3)
|
||||
end
|
||||
|
@ -172,6 +182,7 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
|
|||
arg(:instance_privacy_policy_url, :string)
|
||||
arg(:instance_rules, :string)
|
||||
arg(:registrations_open, :boolean)
|
||||
arg(:instance_languages, list_of(:string))
|
||||
|
||||
resolve(&Admin.save_settings/3)
|
||||
end
|
||||
|
|
|
@ -14,6 +14,7 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
|
|||
field(:long_description, :string)
|
||||
field(:contact, :string)
|
||||
|
||||
field(:languages, list_of(:string))
|
||||
field(:registrations_open, :boolean)
|
||||
field(:registrations_allowlist, :boolean)
|
||||
field(:demo_mode, :boolean)
|
||||
|
|
|
@ -80,10 +80,31 @@ defmodule Mobilizon.Admin do
|
|||
def get_admin_setting_value(group, name, fallback \\ nil)
|
||||
when is_bitstring(group) and is_bitstring(name) do
|
||||
case Repo.get_by(Setting, group: group, name: name) do
|
||||
nil -> fallback
|
||||
%Setting{value: ""} -> fallback
|
||||
%Setting{value: nil} -> fallback
|
||||
%Setting{value: value} -> value
|
||||
nil ->
|
||||
fallback
|
||||
|
||||
%Setting{value: ""} ->
|
||||
fallback
|
||||
|
||||
%Setting{value: nil} ->
|
||||
fallback
|
||||
|
||||
%Setting{value: value} ->
|
||||
get_setting_value(value)
|
||||
end
|
||||
end
|
||||
|
||||
def get_setting_value(value) do
|
||||
case Jason.decode(value) do
|
||||
{:ok, val} ->
|
||||
val
|
||||
|
||||
{:error, _} ->
|
||||
case value do
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
value -> value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -116,7 +137,7 @@ defmodule Mobilizon.Admin do
|
|||
Setting.changeset(%Setting{}, %{
|
||||
group: group,
|
||||
name: Atom.to_string(key),
|
||||
value: to_string(val)
|
||||
value: convert_to_string(val)
|
||||
}),
|
||||
on_conflict: :replace_all,
|
||||
conflict_target: [:group, :name]
|
||||
|
@ -124,4 +145,11 @@ defmodule Mobilizon.Admin do
|
|||
|
||||
do_save_setting(transaction, group, rest)
|
||||
end
|
||||
|
||||
defp convert_to_string(val) do
|
||||
case val do
|
||||
val when is_list(val) -> Jason.encode!(val)
|
||||
val -> to_string(val)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,5 +5,5 @@ defmodule Mobilizon.Cldr do
|
|||
|
||||
use Cldr,
|
||||
locales: ["cs", "de", "en", "es", "fr", "it", "ja", "nl", "pl", "pt", "ru"],
|
||||
providers: [Cldr.Number, Cldr.Calendar, Cldr.DateTime]
|
||||
providers: [Cldr.Number, Cldr.Calendar, Cldr.DateTime, Cldr.Language]
|
||||
end
|
||||
|
|
|
@ -99,6 +99,15 @@ defmodule Mobilizon.Config do
|
|||
)
|
||||
)
|
||||
|
||||
@spec instance_languages :: list(String.t())
|
||||
def instance_languages,
|
||||
do:
|
||||
Mobilizon.Admin.get_admin_setting_value(
|
||||
"instance",
|
||||
"instance_languages",
|
||||
instance_config()[:languages]
|
||||
)
|
||||
|
||||
@spec instance_registrations_allowlist :: list(String.t())
|
||||
def instance_registrations_allowlist, do: instance_config()[:registration_email_allowlist]
|
||||
|
||||
|
@ -319,7 +328,8 @@ defmodule Mobilizon.Config do
|
|||
instance_privacy_policy: instance_privacy(),
|
||||
instance_privacy_policy_type: instance_privacy_type(),
|
||||
instance_privacy_policy_url: instance_privacy_url(),
|
||||
instance_rules: instance_rules()
|
||||
instance_rules: instance_rules(),
|
||||
instance_languages: instance_languages()
|
||||
}
|
||||
end
|
||||
|
||||
|
|
1
mix.exs
1
mix.exs
|
@ -135,6 +135,7 @@ defmodule Mobilizon.Mixfile do
|
|||
{:sitemapper, "~> 0.4.0"},
|
||||
{:xml_builder, "~> 2.1.1", override: true},
|
||||
{:remote_ip, "~> 0.2.0"},
|
||||
{:ex_cldr_languages, "~> 0.2.1"},
|
||||
# Dev and test dependencies
|
||||
{:phoenix_live_reload, "~> 1.2", only: [:dev, :e2e]},
|
||||
{:ex_machina, "~> 2.3", only: [:dev, :test]},
|
||||
|
|
1
mix.lock
1
mix.lock
|
@ -39,6 +39,7 @@
|
|||
"ex_cldr_calendars": {:hex, :ex_cldr_calendars, "1.10.1", "7e45fd1a26711d644c599e06df132669117bee2ff8e54b93f15e14720f821a9b", [:mix], [{:calendar_interval, "~> 0.2", [hex: :calendar_interval, repo: "hexpm", optional: true]}, {:earmark, "~> 1.0", [hex: :earmark, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.13", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:ex_cldr_units, "~> 3.0", [hex: :ex_cldr_units, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "82a024b160719050e1245a67c62c3be535ddeac70d8c25e0ff1ae69f860da4d1"},
|
||||
"ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.7.0", "d302aa589b4c2a28bc274b1944f879c2505aaf52f9427b04687420d55e317b8b", [:mix], [{:ex_cldr, "~> 2.14", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "b47efd833d5297b3644ff2b59a1d3a0c4b90214847626fbd9e52bcc3db7900b2"},
|
||||
"ex_cldr_dates_times": {:hex, :ex_cldr_dates_times, "2.5.4", "18143155be8146a3a8b3def620dc4e54b9676143e381519370b2b3fcf8deefbc", [:mix], [{:calendar_interval, "~> 0.2", [hex: :calendar_interval, repo: "hexpm", optional: true]}, {:ex_cldr_calendars, "~> 1.8", [hex: :ex_cldr_calendars, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.15", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "a9cf13e6c4a42005a817cd20f82b06697595edee741b60379fbf6a207ca6134b"},
|
||||
"ex_cldr_languages": {:hex, :ex_cldr_languages, "0.2.1", "4b7d011fc76c563b9d680fb1d1cbc46e485acd50472a22850b7642f463a750f4", [:mix], [{:ex_cldr, "~> 2.2 and >= 2.2.1", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "052bf33032229300d0c1b82bee277611b13ab03d0f1c15f6ce4dd60da0cfd30a"},
|
||||
"ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.15.4", "1cdeb1f6a22f31e7155edde7d51b3c95ddf6ccf60252a175c10967d4031baebd", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.15", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.6", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "1765b8579b4df5b9fec09bb5506841bf4a51d3dba2c51046e6af05a08fbe7ee1"},
|
||||
"ex_crypto": {:hex, :ex_crypto, "0.10.0", "af600a89b784b36613a989da6e998c1b200ff1214c3cfbaf8deca4aa2f0a1739", [:mix], [{:poison, ">= 2.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm", "ccc7472cfe8a0f4565f97dce7e9280119bf15a5ea51c6535e5b65f00660cde1c"},
|
||||
"ex_doc": {:hex, :ex_doc, "0.22.6", "0fb1e09a3e8b69af0ae94c8b4e4df36995d8c88d5ec7dbd35617929144b62c00", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "1e0aceda15faf71f1b0983165e6e7313be628a460e22a031e32913b98edbd638"},
|
||||
|
|
Loading…
Reference in a new issue