/// /// /// /// /// /// /// declare var wsAmeRedirectorSettings: AmeRedirectorUi.ScriptData; declare var wsAmeLodash: _.LoDashStatic; namespace AmeRedirectorUi { const AllKnownTriggers = { login: null, logout: null, registration: null, firstLogin: null } const _ = wsAmeLodash; type RedirectTrigger = keyof typeof AllKnownTriggers; type TriggerDictionary = { [property in RedirectTrigger]: ValueType; } abstract class AbstractTriggerDictionary implements TriggerDictionary { abstract login: ValueType; abstract logout: ValueType; abstract registration: ValueType; abstract firstLogin: ValueType; } interface RedirectProperties { actorId: string; urlTemplate: string; menuTemplateId?: string; shortcodesEnabled: boolean; trigger: RedirectTrigger; } interface MenuItemProperties { templateId: string; url: string; title: string; } interface StorableSettings { redirects: RedirectProperties[]; } export interface ScriptData extends StorableSettings { usableMenuItems: MenuItemProperties[]; users: MinimalUserProperties[]; hasMoreUsers: boolean; selectedTrigger?: RedirectTrigger; saveFormConfig: AmeKoFreeExtensions.SaveFormConfigFromServer; } const DefaultActorId = 'special:default'; const defaultActor: IAmeActor = new class implements IAmeActor { getDisplayName(): string { return 'Default'; } getId(): string { return DefaultActorId; } isUser(): this is IAmeUser { return false; } hasOwnCap(_: string): boolean | null { return null; } getOwnCapabilities(): CapabilityMap | null { return null; } }(); export class Redirect { protected static inputCounter: number = 0 actorId: RedirectProperties['actorId']; actor: IAmeActor; urlTemplate: KnockoutObservable; menuTemplateId: KnockoutObservable; shortcodesEnabled: KnockoutObservable; trigger: RedirectProperties['trigger']; canToggleShortcodes: KnockoutObservable; inputElementId: string; inputHasFocus: KnockoutObservable; actorTypeNoun: KnockoutObservable; urlDropdownEnabled: KnockoutComputed; constructor(properties: RedirectProperties, actorProvider: ActorProvider|null = null) { this.actorId = properties.actorId; this.trigger = properties.trigger; this.urlTemplate = ko.observable(properties.urlTemplate); this.menuTemplateId = ko.observable( (typeof properties.menuTemplateId === 'string') ? properties.menuTemplateId : '' ); this.canToggleShortcodes = ko.pureComputed(() => { return (this.menuTemplateId().trim() === ''); }); this.inputHasFocus = ko.observable(false); const internalShortcodesEnabled = ko.observable(properties.shortcodesEnabled); this.shortcodesEnabled = ko.computed({ read: () => { //All of the menu items use shortcodes to generate the admin page URL, //so shortcodes must be enabled when a menu item is selected. const menu = this.menuTemplateId().trim(); if (menu !== '') { return true; } return internalShortcodesEnabled(); }, write: (value: boolean) => { if (!this.canToggleShortcodes()) { return; } internalShortcodesEnabled(value); }, deferEvaluation: true }); if (this.actorId === DefaultActorId) { this.actor = defaultActor; } else { const provider: ActorProvider = actorProvider ? actorProvider : AmeActors; const actor = provider.getActor(this.actorId); if (actor !== null) { this.actor = actor; } else { if (console && console.warn) { console.warn('Redirect constructor - Actor not found: ', this.actorId); } const missingActorId = this.actorId; this.actor = new class implements IAmeActor { getDisplayName(): string { return 'Missing role or user'; } getId(): string { return missingActorId; } isUser(): this is IAmeUser { return false; } hasOwnCap(_: string): boolean | null { return null; } getOwnCapabilities(): CapabilityMap | null { return null; } }(); } } this.actorTypeNoun = ko.pureComputed(() => { const prefix = this.actorId.substring(0, this.actorId.indexOf(':')); if (prefix === 'user') { return 'user'; } else if (prefix === 'role') { return 'role' } return 'item'; }); this.urlDropdownEnabled = ko.pureComputed(() => { //If a menu item is already selected in the dropdown, the dropdown has to be enabled //to give the user the ability to select something else. const menu = this.menuTemplateId().trim(); if (menu !== '') { return true; } //The dropdown only contains admin menu items, so it's only useful if the user //can access the admin dashboard after the trigger happens. //Note: This may need to change if we add other options to the dropdown. return (this.trigger === 'login') || (this.trigger === 'firstLogin'); }); Redirect.inputCounter++; this.inputElementId = 'ame-rui-unique-input-' + Redirect.inputCounter; } toJs(): RedirectProperties { let result: RedirectProperties = { actorId: this.actorId, urlTemplate: this.urlTemplate().trim(), shortcodesEnabled: this.shortcodesEnabled(), trigger: this.trigger }; const menu = this.menuTemplateId().trim(); if (menu !== '') { result.menuTemplateId = menu; } return result; } displayName(): string { if (this.actor.hasOwnProperty('userLogin')) { const user = this.actor as IAmeUser; return user.userLogin; } else { return this.actor.getDisplayName(); } } } class TriggerView { users: KnockoutObservableArray = ko.observableArray([] as Redirect[]); roles: KnockoutObservableArray = ko.observableArray([] as Redirect[]); defaultRedirect: KnockoutObservable; supportsUserSettings: boolean = true; supportsRoleSettings: boolean = true; supportsActorSettings: KnockoutComputed; constructor( trigger: RedirectTrigger, supportsUserSettings: boolean|null = null, supportsRoleSettings: boolean|null = null ) { if (supportsUserSettings !== null) { this.supportsUserSettings = supportsUserSettings; } if (supportsRoleSettings !== null) { this.supportsRoleSettings = supportsRoleSettings; } this.supportsActorSettings = ko.pureComputed(() => { return this.supportsUserSettings || this.supportsRoleSettings; }); this.defaultRedirect = ko.observable(new Redirect({ actorId: 'special:default', trigger: trigger, shortcodesEnabled: true, urlTemplate: '' })); } add(item: Redirect) { const actorId = item.actorId; if (actorId === DefaultActorId) { this.defaultRedirect(item); } else if (actorId === 'special:super_admin') { this.roles.push(item); } else { const actorType = actorId.substring(0, actorId.indexOf(':')); switch (actorType) { case 'user': this.users.push(item); break; case 'role': this.roles.push(item); break; default: console.log('Unknown actor type for a trigger view: ' + actorType); } } } toArray(): Redirect[] { let results = []; results.push(...this.users()); results.push(...this.roles()); //Include the default redirect only if it's not empty. const defaultRedirect = this.defaultRedirect(); const url = defaultRedirect.urlTemplate().trim(); if (url !== '') { results.push(defaultRedirect); } return results; } } class MenuCollection { menusByTemplate: AmeDictionary = {}; constructor(usableMenuItems: MenuItemProperties[]) { this.menusByTemplate = {}; for (let i = 0; i < usableMenuItems.length; i++) { this.menusByTemplate[usableMenuItems[i].templateId] = usableMenuItems[i]; } } findSelectedMenu(redirect: Redirect): MenuItemProperties | null { const templateId = redirect.menuTemplateId(); if (templateId === '') { return null; } if (!this.menusByTemplate.hasOwnProperty(templateId)) { return null; } const menu = this.menusByTemplate[templateId]; const url = redirect.urlTemplate(); if (menu.url === url) { return menu; } return null; } } class RedirectsByTrigger extends AbstractTriggerDictionary { firstLogin: TriggerView; login: TriggerView; logout: TriggerView; registration: TriggerView; constructor() { super(); this.login = new TriggerView('login'); this.logout = new TriggerView('logout'); this.registration = new TriggerView('registration', false, false); this.firstLogin = new TriggerView('firstLogin', false, true); } public static fromArray(redirects: Redirect[]): RedirectsByTrigger { const instance = new RedirectsByTrigger(); const length = redirects.length; for (let i = 0; i < length; i++) { const item = redirects[i]; if (instance.hasOwnProperty(item.trigger)) { const view = instance[item.trigger] as TriggerView; view.add(item); } } return instance; } toArray(): Redirect[] { let results: Redirect[] = []; let key: keyof typeof AllKnownTriggers; for (key in AllKnownTriggers) { if (this.hasOwnProperty(key)) { const view = this[key] as TriggerView; results.push(...view.toArray()); } } //Remove redirects that don't have a URL. results = results.filter(function (redirect) { const url = redirect.urlTemplate().trim(); return ((typeof url) === 'string') && (url !== ''); }); return results; } } export class RedirectUrlInputComponent { redirect: Redirect; displayValue: KnockoutComputed; isUrlReadonly: KnockoutComputed; menuItems: MenuCollection; constructor(params: AmeDictionary) { this.redirect = ko.unwrap(params.redirect) as Redirect; this.menuItems = params.menuItems as MenuCollection; this.displayValue = ko.computed({ read: (): string => { const menu = this.menuItems.findSelectedMenu(this.redirect); if (menu) { return menu.title; } else { return this.redirect.urlTemplate(); } }, write: (value: string): void => { const menu = this.menuItems.findSelectedMenu(this.redirect); if (menu !== null) { //Can't manually edit the URL because a menu item is selected. return; } this.redirect.urlTemplate(value); } }); this.isUrlReadonly = ko.pureComputed(() => { if (this.menuItems.findSelectedMenu(this.redirect) !== null) { return true; } return null; }); } } interface ActorProvider { getActor(actorId: string): IAmeActor|null; } /** * Proxy class that automatically creates placeholders for missing actors. */ class ActorProviderProxy implements ActorProvider { private provider: ActorProvider; private readonly placeholders: AmeDictionary; constructor(realProvider: ActorProvider) { this.provider = realProvider; this.placeholders = {}; } getActor(actorId: string): IAmeActor { if (actorId === DefaultActorId) { return defaultActor; } const existingActor = this.provider.getActor(actorId); if (existingActor) { return existingActor; } else if (this.placeholders.hasOwnProperty(actorId)) { return this.placeholders[actorId]; } //If the actor hasn't been loaded or created by now, that means it has been deleted, //or it was invalid to begin with. Let's use a placeholder object to represent it. let missingActor; if (_.startsWith(actorId, 'user:')) { missingActor = new MissingUserPlaceholder(actorId); } else if (_.startsWith(actorId, 'role:')) { missingActor = new MissingRolePlaceholder(actorId); } else { missingActor = new MissingActorPlaceholder(actorId); } this.placeholders[actorId] = missingActor; return missingActor; } } //For this feature we only need enough information to display and identify a user. export interface MinimalUserProperties { user_login: string; display_name: string; } export class MinimalUser extends AmeUser { static createFromProperties(properties: MinimalUserProperties): MinimalUser { return new MinimalUser( properties.user_login, properties.display_name, {}, [], false ); } } class MissingActorPlaceholder implements IAmeActor { protected actorId: string; protected displayName: string; constructor(id: string, displayName: string|null = null) { this.actorId = id; if (displayName !== null) { this.displayName = displayName; } else { this.displayName = this.idWithoutPrefix(id); } } getDisplayName(): string { return this.displayName; } getId(): string { return this.actorId; } protected idWithoutPrefix(actorId: string): string { const delimiterPos = actorId.indexOf(':'); if (delimiterPos < 0) { return actorId; } return actorId.substring(delimiterPos + 1); } isUser(): this is IAmeUser { return false; } hasOwnCap(_: string): boolean | null { return null; } getOwnCapabilities(): CapabilityMap | null { return null; } } class MissingRolePlaceholder extends MissingActorPlaceholder { } class MissingUserPlaceholder extends MissingActorPlaceholder implements IAmeUser { isSuperAdmin: boolean = false; userLogin: string; constructor(actorId: string) { super(actorId); this.userLogin = this.idWithoutPrefix(actorId); } isUser(): this is IAmeUser { return true; } getRoleIds(): string[] { return []; } } export class App { isLoaded: KnockoutObservable = ko.observable(false); redirects: KnockoutObservableArray; selectedTrigger: KnockoutObservable; byTrigger: KnockoutObservable; currentTriggerView: KnockoutComputed; availableTriggers: { trigger: RedirectTrigger, label: string }[] = [ {trigger: 'login', label: 'Login Redirect'}, {trigger: 'logout', label: 'Logout Redirect'}, {trigger: 'registration', label: 'Registration Redirect'}, {trigger: 'firstLogin', label: 'First Login Redirect'} ]; menuItems: MenuCollection; menuDropdownParent: KnockoutObservable; menuDropdownOptions: MenuItemProperties[]; selectedMenuDropdownItem: KnockoutObservable; readonly customUrlOption: MenuItemProperties = { templateId: '', url: '', title: '[ Custom URL ]' }; private readonly menuDropdown: JQuery; private ignoreNextDropdownClick: JQueryEventObject['target']|null = null; addableRoles: KnockoutComputed; selectedRoleToAdd: KnockoutObservable; roleSelectorHasFocus: KnockoutObservable; addableUsers: KnockoutComputed; selectedUserToAdd: KnockoutObservable; userSelectorHasFocus: KnockoutObservable; userSelectionUi: 'dropdown' | 'search' = 'dropdown'; userLoginQuery: KnockoutObservable; addUserButtonEnabled: KnockoutObservable; actorProvider: ActorProviderProxy; readonly saveSettingsForm: AmeKoFreeExtensions.SaveSettingsForm; constructor(settings: ScriptData) { const self = this; this.actorProvider = new ActorProviderProxy(AmeActors); //Users need to be loaded before redirects because redirects use actor objects. let loadedUsers = settings.users.map( (props) => { const existingInstance = AmeActors.getUser(props.user_login); if (existingInstance) { return existingInstance; } else { const newUser = MinimalUser.createFromProperties(props); AmeActors.addUsers([newUser]); return newUser; } } ); loadedUsers.sort(function (a, b) { return a.userLogin.localeCompare(b.userLogin); }); this.redirects = ko.observableArray(settings.redirects.map( props => new Redirect(props, this.actorProvider)) ); this.menuItems = new MenuCollection(settings.usableMenuItems); this.menuDropdownOptions = [this.customUrlOption].concat(settings.usableMenuItems); this.menuDropdownParent = ko.observable(null); this.selectedMenuDropdownItem = ko.computed({ read: () => { const currentRedirect = this.menuDropdownParent(); if (currentRedirect === null) { return this.customUrlOption; } else { //Find the option that matches this template ID and URL. let foundMenu = this.menuItems.findSelectedMenu(currentRedirect); if (foundMenu === null) { foundMenu = this.customUrlOption; } return foundMenu; } }, write: (newValue) => { const currentRedirect = this.menuDropdownParent(); if (!currentRedirect) { return; //Nothing to do! } if (!newValue) { newValue = this.customUrlOption; } currentRedirect.menuTemplateId(newValue.templateId); if (newValue.templateId !== '') { currentRedirect.urlTemplate(newValue.url); } }, owner: self, deferEvaluation: true }); this.menuDropdown = jQuery('#ame-rui-menu-items'); //Hide the dropdown when it loses focus. this.menuDropdown.on('blur', () => { this.closeMenuDropdown(); }); this.menuDropdown.on('keydown', (event) => { //Also hide the dropdown if the user presses Esc. if (event.which === 27) { this.closeMenuDropdown(true); } else if (event.which === 13) { //Close the dropdown when the user presses Enter. //Since we currently update the redirect on every change, there's no difference between //this and pressing Esc. this.closeMenuDropdown(true); } }); //Close the dropdown when the user selects an option by clicking it. this.menuDropdown.on('click', 'option', () => { this.closeMenuDropdown(); }); //this.addTestData(); this.byTrigger = ko.observable(RedirectsByTrigger.fromArray(this.redirects())); //Reselect the previous trigger, or just the first trigger. this.selectedTrigger = ko.observable( settings.selectedTrigger ? settings.selectedTrigger : this.availableTriggers[0].trigger ); this.currentTriggerView = ko.pureComputed(() => { const trigger = this.selectedTrigger(); const mapping = this.byTrigger(); if (mapping.hasOwnProperty(trigger) && (mapping[trigger] instanceof TriggerView)) { return mapping[trigger]; } else { return mapping.login; } }); this.addableRoles = ko.pureComputed(() => { const allRoles: IAmeActor[] = _.values(AmeActors.getRoles()); const usedRoles = _.map( this.currentTriggerView().roles(), (redirect) => { return redirect.actor; } ); return _.difference(allRoles, usedRoles); }); this.selectedRoleToAdd = ko.observable(void 0); this.roleSelectorHasFocus = ko.observable(false); this.addableUsers = ko.pureComputed(() => { const usedUsers = _.map( this.currentTriggerView().users(), (redirect) => { return redirect.actor as IAmeUser; } ); return _.difference(loadedUsers, usedUsers); }); this.selectedUserToAdd = ko.observable(void 0); this.userSelectorHasFocus = ko.observable(false); this.selectedRoleToAdd.subscribe((newSelection) => { this.addSelectedActorTo(newSelection, this.currentTriggerView().roles); this.roleSelectorHasFocus(false); this.selectedRoleToAdd(void 0); }); this.selectedUserToAdd.subscribe((newSelection) => { this.addSelectedActorTo(newSelection, this.currentTriggerView().users); this.userSelectorHasFocus(false); this.selectedUserToAdd(void 0); }); this.userLoginQuery = ko.observable(''); this.addUserButtonEnabled = ko.pureComputed(() => { return (this.userLoginQuery().trim() !== ''); }); if (settings.hasMoreUsers) { this.userSelectionUi = 'search'; } this.saveSettingsForm = new AmeKoFreeExtensions.SaveSettingsForm({ ...settings.saveFormConfig, settingsGetter: () => this.getSettings(), extraFields: [['selectedTrigger', this.selectedTrigger]] }); this.isLoaded(true); } getSettings(): StorableSettings { return { redirects: this.byTrigger().toArray().map(redirect => redirect.toJs()) } } onDropdownTrigger(event: JQueryEventObject) { //Note: There probably is some jQuery feature or library that makes dropdowns easier, //but I already did this the hard way. const $input = jQuery(event.target).closest('.ame-rui-url-template,ame-redirect-url-input').find('input').first(); const $node = $input.closest('.ame-rui-redirect'); if ($node.length < 1) { return; } const redirect = ko.dataFor($node.get(0)); if (!(redirect instanceof AmeRedirectorUi.Redirect)) { return; } //Clicking the same trigger a second time closes the dropdown. if (event.type === 'mousedown') { const isSameTrigger = this.menuDropdown.is(':visible') && (this.menuDropdownParent() === redirect); if (isSameTrigger) { //The dropdown will be automatically closed by its "blur" event handler, //but we need to ignore the next click event on this element. this.ignoreNextDropdownClick = event.target; } else { this.ignoreNextDropdownClick = null; } return; } if ((event.type === 'click') && (event.target === this.ignoreNextDropdownClick)) { return; } //Move the drop-down near the input box. this.menuDropdown .css({ position: 'absolute', zIndex: 100 //The dropdown should be displayed above other elements. This may not be required. }) .show() .outerWidth(Math.max($input.outerWidth(), 100)) .position({ my: 'right top', at: 'right bottom', of: $input }); //Move focus to the dropdown. let $select = this.menuDropdown; if (!this.menuDropdown.is('select, input')) { $select = this.menuDropdown.find('select, input').first(); } $select.trigger('focus'); //Select the current option and scroll it into view. It looks like the browser will automatically //scroll to the selected option, but only if the select element is already visible, so we need to //do this *after* we show the dropdown. this.menuDropdownParent(redirect); } closeMenuDropdown(moveFocusToInput: boolean = false) { const currentRedirect = this.menuDropdownParent(); this.menuDropdown.hide(); this.menuDropdownParent(null); //Refocus on the URL input after closing the dropdown. if (moveFocusToInput && currentRedirect) { currentRedirect.inputHasFocus(true); } } protected addSelectedActorTo(actor: IAmeActor | undefined, list: KnockoutObservableArray) { //The list includes a caption item that is displayed when nothing is selected. //The value of that option is supposed to be undefined. if ((typeof actor === 'undefined') || (actor === null) || !this.currentTriggerView()) { return; } //Add a redirect for the selected role. let newRedirect = new Redirect({ actorId: actor.getId(), shortcodesEnabled: true, urlTemplate: '', trigger: this.selectedTrigger() }, this.actorProvider); list.push(newRedirect); newRedirect.inputHasFocus(true); } addEnteredUserLogin() { const userLogin = this.userLoginQuery().trim(); if (userLogin === '') { return; } const actorId = 'user:' + userLogin; if (!AmeActors.actorExists(actorId)) { if (console && console.warn) { console.warn('User "' + userLogin + '" has not been initialized. Creating a minimal actor now.'); } AmeActors.addUsers([ MinimalUser.createFromProperties({ user_login: userLogin, display_name: userLogin }) ]); } //Only add each user once. const alreadyAdded = _.some(this.currentTriggerView().users(), function (redirect) { return redirect.actorId === actorId; }); if (alreadyAdded) { alert('Error: Duplicate entry. User "' + userLogin + '" has already been added.'); return; } let newRedirect = new Redirect({ actorId: actorId, shortcodesEnabled: true, urlTemplate: '', trigger: this.selectedTrigger() }, this.actorProvider); this.currentTriggerView().users.push(newRedirect); this.userLoginQuery(''); } filterUserAutocompleteResults(results: MinimalUserProperties[]): MinimalUserProperties[] { //Filter out users that are already in the current list. const usedLogins = _.keyBy( this.currentTriggerView().users(), (redirect) => { return (redirect.actor as IAmeUser).userLogin; } ); return _.filter(results, function (props) { return !(usedLogins.hasOwnProperty(props.user_login)); }); } isMissingActor(actor: IAmeActor): boolean { return (actor instanceof MissingActorPlaceholder); } private addTestData() { //Add some test data. this.redirects.push(new Redirect({ actorId: 'role:editor', urlTemplate: '[wp-admin]edit.php', trigger: 'login', shortcodesEnabled: true }, this.actorProvider)); this.redirects.push(new Redirect({ actorId: 'role:author', urlTemplate: '[wp-admin]profile.php', trigger: 'login', shortcodesEnabled: true }, this.actorProvider)); this.redirects.push(new Redirect({ actorId: 'user:admin', urlTemplate: '[wp-admin]index.php', trigger: 'login', shortcodesEnabled: true }, this.actorProvider)); this.redirects.push(new Redirect({ actorId: 'role:contributor', urlTemplate: '[wp-admin]index.php', trigger: 'login', shortcodesEnabled: true }, this.actorProvider)); this.redirects.push(new Redirect({ actorId: 'role:nonexistent', urlTemplate: '[wp-admin]options-general.php', trigger: 'login', shortcodesEnabled: true }, this.actorProvider)); this.redirects.push(new Redirect({ actorId: 'user:notarealuser', urlTemplate: '[wp-admin]index.php', trigger: 'login', shortcodesEnabled: true }, this.actorProvider)); this.redirects.push(new Redirect({ actorId: DefaultActorId, urlTemplate: '[wp-admin]index.php?this-is-the-default=yep', trigger: 'login', shortcodesEnabled: true }, this.actorProvider)); this.redirects.push(new Redirect({ actorId: 'role:administrator', urlTemplate: '[wp-admin]options-general.php', trigger: 'login', shortcodesEnabled: true }, this.actorProvider)); } } } jQuery(function ($) { ko.components.register( 'ame-redirect-url-input', { viewModel: AmeRedirectorUi.RedirectUrlInputComponent, template: {element: 'ame-redirect-url-component'} } ); //The user autocomplete feature is implemented as a custom binding only because that makes it easier //to correctly initialise it when Knockout changes the DOM. The binding is not intended to be reusable. ko.bindingHandlers.ameRuiUserAutocomplete = { init: function (element, valueAccessor) { let options = ko.unwrap(valueAccessor()); options = wsAmeLodash.defaults(options, { filter: function (suggestions:AmeRedirectorUi.MinimalUserProperties[]) { return suggestions; } }); jQuery(element).autocomplete({ minLength: 2, source: function (request: any, response:(results: any[]) => void) { const action = AjawV2.getAction('ws-ame-rui-search-users'); action.get( {term: request.term}, function (results) { if (Array.isArray(results)) { let resultsAsArray = results; //Filter received users. if (options.filter) { resultsAsArray = options.filter(resultsAsArray); } response(resultsAsArray) } else { response([]); if (console && console.warn) { console.warn('Invalid response from the server (not an array):', results); } } }, function (error) { response([]); if (console && console.error) { console.error(error); } } ); }, select: function (unusedEvent, ui) { const props = ui.item as AmeRedirectorUi.MinimalUserProperties; const existingUser = AmeActors.getUser(props.user_login); if (existingUser === null) { AmeActors.addUsers([AmeRedirectorUi.MinimalUser.createFromProperties(props)]); } }, classes: { 'ui-autocomplete': 'ame-rui-found-users' } }); ko.utils.domNodeDisposal.addDisposeCallback(element, function () { jQuery(element).autocomplete('destroy'); }); } }; const $container = jQuery('#ame-redirector-ui-root'); const ameRedirectorApp = new AmeRedirectorUi.App(wsAmeRedirectorSettings); ko.applyBindings(ameRedirectorApp, $container.get(0)); //Open the menu dropdown when the user clicks the trigger icon or presses //the down arrow key in the redirect input field. $container.on('mousedown click', '.ame-rui-url-dropdown-trigger', function (event) { ameRedirectorApp.onDropdownTrigger(event); }); /* Releasing the "down" key only opens the dropdown if the key was pressed in the same input. This is to avoid a confusing situation where the user selects a role from the "add a role" dropdown using arrow keys and then the menu dropdown immediately shows up because the focus moved to the redirect input before the user could release the key. */ const redirectInputSelector = '.ame-rui-url-template input[type=text].ame-rui-has-url-dropdown'; let lastDownArrowTarget: Element | null = null; $container.on('focus', redirectInputSelector, function () { lastDownArrowTarget = null; }); $container.on('keydown', redirectInputSelector, function (event) { //Ignore repeated "keydown" events. These will happen even if the key was originally //pressed in a different element. if (event.originalEvent instanceof KeyboardEvent) { if ((typeof event.originalEvent['repeat'] !== 'undefined') && event.originalEvent['repeat']) { return; } } if (event.which === 40) { lastDownArrowTarget = event.target; } }); $container.on('keyup', redirectInputSelector, function (event) { if ((event.which === 40) && (event.target === lastDownArrowTarget)) { ameRedirectorApp.onDropdownTrigger(event); } }); });