diff --git a/app/components/live_release/metadata_component.html.erb b/app/components/live_release/metadata_component.html.erb index 621468616..e47965d04 100644 --- a/app/components/live_release/metadata_component.html.erb +++ b/app/components/live_release/metadata_component.html.erb @@ -13,24 +13,24 @@
<%= render ButtonComponent.new(label: "Play Store metadata requirements", - scheme: :link, - type: :link_external, - options: "https://play.google.com/console/about/storelistings/#best-practices", - html_options: { class: "text-sm" }, - authz: false, - size: :none, - arrow: :none) do |b| + scheme: :link, + type: :link_external, + options: "https://play.google.com/console/about/storelistings/#best-practices", + html_options: { class: "text-sm" }, + authz: false, + size: :none, + arrow: :none) do |b| b.with_icon("integrations/logo_google_play_store.png", size: :md, classes: "text-main") end %> <%= render ButtonComponent.new(label: "App Store Connect metadata requirements", - scheme: :link, - type: :link_external, - options: "https://help.apple.com/asc/appsspec/en.lproj/static.html", - html_options: { class: "text-sm" }, - authz: false, - size: :none, - arrow: :none) do |b| + scheme: :link, + type: :link_external, + options: "https://help.apple.com/asc/appsspec/en.lproj/static.html", + html_options: { class: "text-sm" }, + authz: false, + size: :none, + arrow: :none) do |b| b.with_icon("integrations/logo_app_store.png", size: :md, classes: "text-main") end %>
@@ -81,7 +81,11 @@ <% f.F.fields_for :android, android_metadata do |aF| %>
<%= aF.hidden_field :id %> -
<%= aF.labeled_textarea :release_notes, "Release Notes" %>
+ <%= render partial: "shared/size_limited_textarea", locals: { form: aF, + obj_method: :release_notes, + label_text: "Release Notes", + max_length: android_max_length, + existing_value: android_metadata.release_notes } %>
<% end %> <% elsif component.cross_platform? %> @@ -94,8 +98,16 @@ <% f.F.fields_for :ios, ios_metadata do |iosF| %>
<%= iosF.hidden_field :id %> -
<%= iosF.labeled_textarea :release_notes, "What's New?" %>
-
<%= iosF.labeled_textarea :promo_text, "Promo Text" %>
+ <%= render partial: "shared/size_limited_textarea", locals: { form: iosF, + obj_method: :release_notes, + label_text: "Release Notes", + max_length: ios_max_length, + existing_value: ios_metadata.release_notes } %> + <%= render partial: "shared/size_limited_textarea", locals: { form: iosF, + obj_method: :promo_text, + label_text: "Promo Text", + max_length: promo_text_max_length, + existing_value: ios_metadata.promo_text } %>
<%= iosF.labeled_textarea :keywords, "Keywords", readonly: true, disabled: true %>
<%= iosF.labeled_textarea :description, "Description", readonly: true, disabled: true %>
diff --git a/app/components/live_release/metadata_component.rb b/app/components/live_release/metadata_component.rb index 349e5b319..0df558169 100644 --- a/app/components/live_release/metadata_component.rb +++ b/app/components/live_release/metadata_component.rb @@ -27,4 +27,16 @@ def no_locale_set def editable? @release.release_platform_runs.any?(&:metadata_editable?) end + + def android_max_length + ReleaseMetadata::ANDROID_NOTES_MAX_LENGTH + end + + def ios_max_length + ReleaseMetadata::IOS_NOTES_MAX_LENGTH + end + + def promo_text_max_length + ReleaseMetadata::PROMO_TEXT_MAX_LENGTH + end end diff --git a/app/javascript/controllers/character_counter_controller.js b/app/javascript/controllers/character_counter_controller.js new file mode 100644 index 000000000..a5c710b6d --- /dev/null +++ b/app/javascript/controllers/character_counter_controller.js @@ -0,0 +1,26 @@ +import { Controller } from "@hotwired/stimulus" + +const ERROR_CLASS = "text-rose-700" + +export default class extends Controller { + static targets = ["input", "counter"] + static values = { + maxLength: {type: Number, default: 500}, + } + + connect() { + this.update() + } + + update() { + const value = this.inputTarget.value.length + + if (value > this.maxLengthValue) { + this.counterTarget.classList.add(ERROR_CLASS) + } else { + this.counterTarget.classList.remove(ERROR_CLASS) + } + + this.counterTarget.innerHTML = value + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index f55774164..8ac0408da 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -26,3 +26,6 @@ application.register("tabs", Tabs) import { Popover } from "tailwindcss-stimulus-components" application.register("popover", Popover) + +import TextareaAutogrow from "stimulus-textarea-autogrow" +application.register("textarea-autogrow", TextareaAutogrow) diff --git a/app/models/release_metadata.rb b/app/models/release_metadata.rb index 896677e88..b125b08a3 100644 --- a/app/models/release_metadata.rb +++ b/app/models/release_metadata.rb @@ -22,21 +22,29 @@ class ReleaseMetadata < ApplicationRecord IOS_NOTES_MAX_LENGTH = 4000 ANDROID_NOTES_MAX_LENGTH = 500 + PROMO_TEXT_MAX_LENGTH = 170 + IOS_DENY_LIST = %w[<] # NOTE: Refer to https://www.regular-expressions.info/unicode.html for supporting more unicode characters - PLAINTEXT_REGEX = /\A[~₹!@#$%^&*()_+\-=\[\]{};':"\\|`,.\/?\s\p{Alnum}\p{P}\p{Zs}\p{Emoji_Presentation}\p{M}\p{N}]+\z/ - DEFAULT_LOCALES = ["en-US", "en-GB", "hi-IN", "en-IN", "id"] - DEFAULT_LOCALE = DEFAULT_LOCALES.first + IOS_PLAINTEXT_REGEX = /\A(?!.*#{Regexp.union(IOS_DENY_LIST)})[\p{L}\p{N}\p{P}\p{Sm}\p{Sc}\p{Zs}\p{M}\n]+\z/ + ANDROID_PLAINTEXT_REGEX = /\A[\p{L}\p{N}\p{P}\p{Sm}\p{Sc}\p{Zs}\p{M}\p{Emoji_Presentation}\p{Extended_Pictographic}\n]+\z/ + DEFAULT_LOCALE = "en-US" DEFAULT_LANGUAGE = "English (United States)" DEFAULT_RELEASE_NOTES = "The latest version contains bug fixes and performance improvements." validates :release_notes, - format: {with: PLAINTEXT_REGEX, message: :no_special_characters, multiline: true} + format: {with: IOS_PLAINTEXT_REGEX, message: :no_special_characters_ios, denied_characters: IOS_DENY_LIST.join(", "), multiline: true}, + if: :ios? + validates :release_notes, + format: {with: ANDROID_PLAINTEXT_REGEX, message: :no_special_characters_android, multiline: true}, + if: :android? validates :promo_text, - format: {with: PLAINTEXT_REGEX, message: :no_special_characters, allow_blank: true, multiline: true}, - length: {maximum: 170} + format: {with: IOS_PLAINTEXT_REGEX, message: :no_special_characters, allow_blank: true, multiline: true}, + length: {maximum: PROMO_TEXT_MAX_LENGTH} validates :locale, uniqueness: {scope: :release_platform_run_id} validate :notes_length + delegate :ios?, :android?, to: :release_platform_run + # NOTE: strip and normalize line endings across various OSes normalizes :release_notes, with: ->(notes) { notes.strip.gsub("\r\n", "\n") } @@ -52,10 +60,8 @@ def notes_length end def notes_max_length - case release_platform_run.platform - when "android" then ANDROID_NOTES_MAX_LENGTH - when "ios" then IOS_NOTES_MAX_LENGTH - else raise ArgumentError, "Invalid platform" - end + return ANDROID_NOTES_MAX_LENGTH if android? + return IOS_NOTES_MAX_LENGTH if ios? + raise ArgumentError, "Invalid platform" end end diff --git a/app/views/shared/_size_limited_textarea.html.erb b/app/views/shared/_size_limited_textarea.html.erb new file mode 100644 index 000000000..4e59dce3b --- /dev/null +++ b/app/views/shared/_size_limited_textarea.html.erb @@ -0,0 +1,10 @@ +
+ <%= form.labeled_textarea obj_method, label_text, data: { character_counter_target: "input", + action: "input->character-counter#update", + controller: "textarea-autogrow" } %> +

+ Characters – + <%= existing_value&.length || 0 %> + / <%= max_length %> +

+
diff --git a/config/importmap.rb b/config/importmap.rb index 17b8e89d5..9b7f70541 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -44,3 +44,4 @@ pin "@sentry-internal/replay", to: "https://ga.jspm.io/npm:@sentry-internal/replay@8.33.1/build/npm/esm/index.js" pin "@sentry-internal/replay-canvas", to: "https://ga.jspm.io/npm:@sentry-internal/replay-canvas@8.33.1/build/npm/esm/index.js" pin "tom-select", to: "https://ga.jspm.io/npm:tom-select@2.3.1/dist/js/tom-select.complete.js" +pin "stimulus-textarea-autogrow", to: "https://ga.jspm.io/npm:stimulus-textarea-autogrow@4.1.0/dist/stimulus-textarea-autogrow.mjs" diff --git a/config/locales/en.yml b/config/locales/en.yml index 238bdd3cb..cb6f6dcc9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -477,10 +477,11 @@ en: release_metadata: attributes: release_notes: - no_special_characters: "only allows letters, numbers, emojis, and some special characters" + no_special_characters_ios: "for iOS can only contain letters, numbers, punctuation, basic math symbols, currency symbols, and line breaks. The following characters are not allowed – %{denied_characters}" + no_special_characters_android: "for Android can only contain letters, numbers, punctuation, basic math symbols, currency symbols, emojis, and line breaks" too_long: "is too long for %{platform} (maximum is %{max_length} characters)" promo_text: - no_special_characters: "only allows letters, numbers, emojis, and some special characters" + no_special_characters: "can only contain letters, numbers, punctuation, basic math symbols, currency symbols, and line breaks. The following characters are not allowed – %{denied_characters}" integration: format: "%{message}" attributes: diff --git a/spec/models/release_metadata_spec.rb b/spec/models/release_metadata_spec.rb index 5dc0ce78e..05f83e5e5 100644 --- a/spec/models/release_metadata_spec.rb +++ b/spec/models/release_metadata_spec.rb @@ -3,27 +3,79 @@ require "rails_helper" RSpec.describe ReleaseMetadata do + let(:locale) { "en-GB" } + it "has a valid factory" do expect(build(:release_metadata)).to be_valid end - it "allows emoji characters in notes" do - expect(build(:release_metadata, promo_text: "😀")).to be_valid - end + context "when iOS" do + let(:release_platform) { create(:release_platform, platform: :ios) } + let(:release_platform_run) { create(:release_platform_run, release_platform:) } - it "allows some special characters in notes" do - expect(build(:release_metadata, promo_text: "Money money money!! ₹100 off! $$ bills yo?! (#money)")).to be_valid - end + it "disallow emoji characters in notes" do + expect(build(:release_metadata, locale:, release_platform_run:, release_notes: "➡️ something\n😀 💃🏽")).not_to be_valid + end - it "allows accented characters in notes" do - expect(build(:release_metadata, promo_text: "À la mode, les élèves sont bien à l'aise.")).to be_valid - end + it "allows currencies in notes" do + expect(build(:release_metadata, locale:, release_platform_run:, release_notes: "Money money money!! ₹100 off! => $$ bills yo?! (#money)")).to be_valid + end + + it "allows accented characters in notes" do + expect(build(:release_metadata, locale:, release_platform_run:, release_notes: "À la mode, les élèves sont bien à l'aise.")).to be_valid + end + + it "allows non-latin characters in notes" do + expect(build(:release_metadata, locale:, release_platform_run:, release_notes: "दिल ढूँढता है फिर वही फ़ुरसत के रात दिन, बैठे रहे तसव्वुर-ए-जानाँ किये हुए।")).to be_valid + end + + it "allows numbers in non-latin languages in notes" do + expect(build(:release_metadata, locale:, release_platform_run:, release_notes: "१२३४५६७८९१०१११२१३, १३ करूँ गिन गिन के")).to be_valid + end + + it "allows up to 4000 characters in notes" do + expect(build(:release_metadata, locale:, release_platform_run:, release_notes: "a" * 4000)).to be_valid + end - it "allows non-latin characters in notes" do - expect(build(:release_metadata, promo_text: "दिल ढूँढता है फिर वही फ़ुरसत के रात दिन, बैठे रहे तसव्वुर-ए-जानाँ किये हुए।")).to be_valid + it "disallows more than 4000 characters in notes" do + expect(build(:release_metadata, locale:, release_platform_run:, release_notes: "a" * 4001)).not_to be_valid + end + + it "disallows '<' in notes" do + expect(build(:release_metadata, locale:, release_platform_run:, release_notes: "")).not_to be_valid + end end - it "allows numbers in non-latin languages in notes" do - expect(build(:release_metadata, promo_text: "१२३४५६७८९१०१११२१३, १३ करूँ गिन गिन के")).to be_valid + context "when android" do + let(:release_platform) { create(:release_platform, platform: :android) } + let(:release_platform_run) { create(:release_platform_run, release_platform:) } + + it "allows emoji characters in notes" do + expect(build(:release_metadata, locale:, release_platform_run:, release_notes: "➡️ something\n😀 💃🏽")).to be_valid + end + + it "allows currencies in notes" do + expect(build(:release_metadata, locale:, release_platform_run:, release_notes: "Money money money!! ₹100 off! => $$ bills yo?! (#money)")).to be_valid + end + + it "allows accented characters in notes" do + expect(build(:release_metadata, locale:, release_platform_run:, release_notes: "À la mode, les élèves sont bien à l'aise.")).to be_valid + end + + it "allows non-latin characters in notes" do + expect(build(:release_metadata, locale:, release_platform_run:, release_notes: "दिल ढूँढता है फिर वही फ़ुरसत के रात दिन, बैठे रहे तसव्वुर-ए-जानाँ किये हुए।")).to be_valid + end + + it "allows numbers in non-latin languages in notes" do + expect(build(:release_metadata, locale:, release_platform_run:, release_notes: "१२३४५६७८९१०१११२१३, १३ करूँ गिन गिन के")).to be_valid + end + + it "allows up to 500 characters in notes" do + expect(build(:release_metadata, locale:, release_platform_run:, release_notes: "a" * 500)).to be_valid + end + + it "disallows more than 500 characters in notes" do + expect(build(:release_metadata, locale:, release_platform_run:, release_notes: "a" * 501)).not_to be_valid + end end end