forked from forem/forem
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Admin setting to control enabled countries for billboard geotargeting (…
…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
1 parent
eb17b73
commit 3f92292
Showing
26 changed files
with
623 additions
and
34 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.