Skip to content

Commit

Permalink
Admin setting to control enabled countries for billboard geotargeting (
Browse files Browse the repository at this point in the history
…forem#20083)

* use instance setting for enabled target geolocations

* add validation for enabled geolocations setting

* a start on the UI?

* backend tweaks for UI

* proper crack at autocomplete component

* fix region targeting toggle

* e2e spec
  • Loading branch information
filleduchaos authored Sep 12, 2023
1 parent eb17b73 commit 3f92292
Show file tree
Hide file tree
Showing 26 changed files with 623 additions and 34 deletions.
10 changes: 9 additions & 1 deletion app/controllers/admin/settings/general_settings_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,25 @@ def authorization_resource
end

def settings_params
params.require(:settings_general)&.permit(
params.require(:settings_general)&.merge(parsed_countries)&.permit(
settings_keys.map(&:to_sym),
social_media_handles: ::Settings::General::SOCIAL_MEDIA_SERVICES,
meta_keywords: ::Settings::General.meta_keywords.keys,
credit_prices_in_cents: ::Settings::General.credit_prices_in_cents.keys,
billboard_enabled_countries: ISO3166::Country.codes,
)
end

def settings_keys
::Settings::General.keys + SPECIAL_PARAMS_TO_ADD
end

def parsed_countries
countries = params[:settings_general][:billboard_enabled_countries]
return {} unless countries

{ billboard_enabled_countries: JSON.parse(countries) }
end
end
end
end
11 changes: 11 additions & 0 deletions app/helpers/admin/settings_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module Admin
module SettingsHelper
def billboard_enabled_countries_for_editing
::Settings::General.billboard_enabled_countries.to_json
end

def billboard_all_countries_for_editing
ISO3166::Country.all.to_h { |country| [country.alpha2, country.common_name] }
end
end
end
62 changes: 62 additions & 0 deletions app/javascript/billboard/locations/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { h } from 'preact';
import { useCallback } from 'preact/hooks';
import PropTypes from 'prop-types';
import { MultiSelectAutocomplete } from '@crayons';

export { SelectedLocation } from './templates';

export const Locations = ({
defaultValue = [],
allLocations,
inputId,
onChange,
placeholder = 'Enter a country name...',
template,
}) => {
const autocompleteLocations = useCallback(
(query) => {
return new Promise((resolve) => {
queueMicrotask(() => {
const suggestions = [];
const caseInsensitiveQuery = query.toLowerCase();
Object.keys(allLocations).forEach((name) => {
if (name.toLowerCase().indexOf(caseInsensitiveQuery) > -1) {
suggestions.push(allLocations[name]);
}
});
resolve(suggestions);
});
});
},
[allLocations],
);

return (
<MultiSelectAutocomplete
defaultValue={defaultValue}
fetchSuggestions={autocompleteLocations}
border
labelText="Enabled countries for targeting"
placeholder={placeholder}
SelectionTemplate={template}
onSelectionsChanged={onChange}
inputId={inputId}
allowUserDefinedSelections={false}
/>
);
};

const locationsShape = PropTypes.shape({
name: PropTypes.string.isRequired,
code: PropTypes.string.isRequired,
withRegions: PropTypes.bool,
});

Locations.propTypes = {
defaultValue: PropTypes.arrayOf(locationsShape),
allLocations: PropTypes.objectOf(locationsShape).isRequired,
inputId: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
placeholder: PropTypes.string,
template: PropTypes.elementType,
};
48 changes: 48 additions & 0 deletions app/javascript/billboard/locations/templates.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { h } from 'preact';
import { useCallback } from 'preact/hooks';
import { ButtonNew as Button, Icon } from '@crayons';
import { Close } from '@images/x.svg';

/**
* Higher-order component that returns a template responsible for the layout of
* a selected location
*
* @returns {h.JSX.ElementType}
*/
export const SelectedLocation = ({
displayName,
onNameClick,
label,
ExtraInfo,
}) => {
const Template = ({ onEdit: _, onDeselect, ...location }) => {
const onClick = useCallback(() => onNameClick(location), [location]);

return (
<div
role="group"
aria-label={location.name}
className="c-autocomplete--multi__tag-selection flex mr-2 mb-2 w-max"
>
<Button
aria-label={label}
onClick={onClick}
className="c-autocomplete--multi__selected p-1 flex flex-col"
>
{location.name}
{ExtraInfo && <ExtraInfo {...location} />}
</Button>
<Button
aria-label={`Remove ${location.name}`}
onClick={onDeselect}
className="c-autocomplete--multi__selected p-1"
>
<Icon src={Close} />
</Button>
</div>
);
};

Template.displayName = displayName;
return Template;
};
91 changes: 91 additions & 0 deletions app/javascript/packs/admin/billboardEnabledCountries.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { h, render } from 'preact';
import { Locations, SelectedLocation } from '../../billboard/locations';

const RegionMarker = ({ withRegions }) => {
return (
<span className="fs-xs fw-bold">
{withRegions ? 'Including' : 'Excluding'} regions
</span>
);
};

function parseDOMState(hiddenField) {
const countriesByCode = JSON.parse(hiddenField.dataset.allCountries);

const allCountries = {};
for (const [code, name] of Object.entries(countriesByCode)) {
allCountries[name] = { name, code };
}
const existingSetting = JSON.parse(hiddenField.value);
const selectedCountries = Object.keys(existingSetting).map((code) => ({
name: countriesByCode[code],
code,
withRegions: existingSetting[code] === 'with_regions',
}));

return { allCountries, selectedCountries };
}

function syncSelectionsToDOM(hiddenField, countries) {
const newValue = countries.reduce((value, { code, withRegions }) => {
value[code] = withRegions ? 'with_regions' : 'without_regions';
return value;
}, {});
hiddenField.value = JSON.stringify(newValue);
}

/**
* Sets up and renders a Preact component to handle searching for and enabling
* countries for targeting (and, per country, to enable region-level targeting).
*/
function setupEnabledCountriesEditor() {
const editor = document.getElementById('billboard-enabled-countries-editor');
const hiddenField = document.querySelector('.geolocation-multiselect');

if (!(editor && hiddenField)) return;

const { allCountries, selectedCountries } = parseDOMState(hiddenField);
let currentSelections = selectedCountries;

function setCountriesSelection(countries) {
currentSelections = countries;
syncSelectionsToDOM(hiddenField, currentSelections);
}

function updateRegionSetting(country) {
const selected = currentSelections.find(
(selectedCountry) => selectedCountry.code === country.code,
);
selected.withRegions = !selected.withRegions;
syncSelectionsToDOM(hiddenField, currentSelections);
renderLocations();
}

const EnabledCountry = SelectedLocation({
displayName: 'EnabledCountry',
onNameClick: updateRegionSetting,
label: 'Toggle region targeting',
ExtraInfo: RegionMarker,
});

function renderLocations() {
render(
<Locations
defaultValue={currentSelections}
onChange={setCountriesSelection}
inputId="billboard-enabled-countries-editor"
allLocations={allCountries}
template={EnabledCountry}
/>,
editor,
);
}

renderLocations();
}

if (document.readyState !== 'loading') {
setupEnabledCountriesEditor();
} else {
document.addEventListener('DOMContentLoaded', setupEnabledCountriesEditor);
}
3 changes: 3 additions & 0 deletions app/lib/constants/settings/general.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ def self.details
ahoy_tracking: {
description: I18n.t("lib.constants.settings.general.ahoy_tracking.description")
},
billboard_enabled_countries: {
description: I18n.t("lib.constants.settings.general.billboard_enabled_countries.description")
},
contact_email: {
description: I18n.t("lib.constants.settings.general.contact_email.description"),
placeholder: "hello@example.com"
Expand Down
2 changes: 1 addition & 1 deletion app/models/billboard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def validate_tag

def validate_geolocations
target_geolocations.each do |geo|
unless geo.valid?
unless geo.valid?(:targeting)
errors.add(:target_geolocations, I18n.t("models.billboard.invalid_location", location: geo.to_iso3166))
end
end
Expand Down
22 changes: 15 additions & 7 deletions app/models/geolocation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,10 @@ def deserialize(geo_ltrees)
FEATURE_FLAG = :billboard_location_targeting
ISO3166_SEPARATOR = "-".freeze
LTREE_SEPARATOR = ".".freeze
# Maybe this should be a config of some kind...?
PERMITTED_COUNTRIES = [
ISO3166::Country.code_from_name("United States"),
ISO3166::Country.code_from_name("Canada"),
].freeze
DEFAULT_ENABLED_COUNTRIES = {
ISO3166::Country.code_from_name("United States") => :with_regions,
ISO3166::Country.code_from_name("Canada") => :with_regions
}.freeze

attr_reader :country_code, :region_code

Expand All @@ -60,11 +59,13 @@ def initialize(country_code, region_code = nil)
@region_code = region_code
end

# validates :country_code, inclusion: { in: ISO3166::Country.codes }
validates :country_code, inclusion: { in: PERMITTED_COUNTRIES }
validates :country_code, inclusion: {
in: ->(_) { Settings::General.billboard_enabled_countries.keys }
}
validates :region_code, inclusion: {
in: ->(geolocation) { ISO3166::Country.region_codes_if_exists(geolocation.country_code) }
}, allow_nil: true
validate :valid_region_for_targeting, on: :targeting

def ==(other)
country_code == other.country_code && region_code == other.region_code
Expand All @@ -88,6 +89,13 @@ def to_sql_query_clause(column_name = :target_geolocations)
"'#{lquery}' ~ #{column_name}"
end

def valid_region_for_targeting
return if region_code.nil? ||
Settings::General.billboard_enabled_countries[country_code] == :with_regions

errors.add(:region_code, "was provided on a country with region targeting disabled")
end

def errors_as_sentence
errors.full_messages.to_sentence
end
Expand Down
4 changes: 4 additions & 0 deletions app/models/settings/general.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ class General < Base
setting :payment_pointer, type: :string
setting :stripe_api_key, type: :string, default: ApplicationConfig["STRIPE_SECRET_KEY"]
setting :stripe_publishable_key, type: :string, default: ApplicationConfig["STRIPE_PUBLISHABLE_KEY"]
# Billboard-related. Not sure this is the best place for it, but it's a start.
setting :billboard_enabled_countries, type: :hash, default: Geolocation::DEFAULT_ENABLED_COUNTRIES, validates: {
enabled_countries_hash: true
}

# Newsletter
# <https://mailchimp.com/developer/>
Expand Down
1 change: 1 addition & 0 deletions app/services/settings/general/upsert.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def self.clean_params(settings)
settings[param] = settings[param]&.downcase&.delete(" ") if settings[param]
end
settings[:credit_prices_in_cents]&.transform_values!(&:to_i)
settings[:billboard_enabled_countries]&.transform_values!(&:to_sym)
settings
end

Expand Down
21 changes: 21 additions & 0 deletions app/validators/enabled_countries_hash_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
class EnabledCountriesHashValidator < ActiveModel::EachValidator
VALID_HASH_VALUES = %i[with_regions without_regions].freeze

def validate_each(record, attribute, value)
if value.blank? || !value.is_a?(Hash)
record.errors.add(attribute,
options[:message] || I18n.t("validators.iso3166_hash_validator.is_blank"))
return
end

unless value.keys.all? { |key| ISO3166::Country.codes.include? key }
record.errors.add(attribute,
options[:message] || I18n.t("validators.iso3166_hash_validator.invalid_key"))
end

return if value.values.all? { |value| VALID_HASH_VALUES.include? value }

record.errors.add(attribute,
options[:message] || I18n.t("validators.iso3166_hash_validator.invalid_value"))
end
end
17 changes: 17 additions & 0 deletions app/views/admin/settings/forms/_monetization.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@
value: Settings::General.payment_pointer,
placeholder: Constants::Settings::General.details[:payment_pointer][:placeholder] %>
</div>

<% if FeatureFlag.enabled?(Geolocation::FEATURE_FLAG) %>
<div class="crayons-field">
<%= admin_config_label :billboard_enabled_countries %>
<%= admin_config_description Constants::Settings::General.details[:billboard_enabled_countries][:description] %>
<div id="billboard-enabled-countries-editor"></div>
</div>

<div class="crayons-field hidden">
<%= f.text_field :billboard_enabled_countries,
class: "crayons-textfield geolocation-multiselect",
value: billboard_enabled_countries_for_editing,
autocomplete: "off",
data: { all_countries: billboard_all_countries_for_editing } %>
</div>
<%= javascript_packs_with_chunks_tag "admin/billboardEnabledCountries", defer: true %>
<% end %>
</fieldset>
<%= render "update_setting_button", f: f %>
</div>
Expand Down
2 changes: 2 additions & 0 deletions config/locales/lib/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ en:
general:
ahoy_tracking:
description: Track visits and events - data is stored in your database. A restart is required for changes to this configuration to take effect.
billboard_enabled_countries:
description: Countries that can be geotargeted by billboards. Click on a selected country to toggle targeting on a sub-country level.
contact_email:
description: Used for contact links. Please provide an email address where users can get in touch with you or your team.
credit:
Expand Down
2 changes: 2 additions & 0 deletions config/locales/lib/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ fr:
general:
ahoy_tracking:
description: Track visits and events - data is stored in your database. A restart is required for changes to this configuration to take effect.
billboard_enabled_countries:
description: Countries that can be geotargeted by billboards. Click on a selected country to toggle targeting on a sub-country level.
contact_email:
description: Used for contact links. Please provide an email address where users can get in touch with you or your team.
credit:
Expand Down
2 changes: 1 addition & 1 deletion config/locales/models/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ en:
experience4: Have experience level four
experience5: Have experience level five
billboard:
invalid_location: '%{location} is not a supported ISO 3166-2 code'
invalid_location: '%{location} is not an enabled target ISO 3166-2 code'
broadcast:
single_active: You can only have one active announcement broadcast
comment:
Expand Down
Loading

0 comments on commit 3f92292

Please sign in to comment.