diff options
Diffstat (limited to 'app/assets/javascripts/invite_members')
5 files changed, 357 insertions, 81 deletions
diff --git a/app/assets/javascripts/invite_members/components/confetti.vue b/app/assets/javascripts/invite_members/components/confetti.vue new file mode 100644 index 00000000000..2e5744afcd4 --- /dev/null +++ b/app/assets/javascripts/invite_members/components/confetti.vue @@ -0,0 +1,33 @@ +<script> +import confetti from 'canvas-confetti'; + +export default { + mounted() { + confetti.create(this.$refs.canvas, { + resize: true, + useWorker: true, + disableForReducedMotion: true, + }); + + this.basicCannon(); + }, + methods: { + basicCannon() { + confetti({ + particleCount: 100, + spread: 70, + origin: { y: 0.2 }, + scalar: 2, + shapes: ['square'], + colors: ['#FC6D26', '#6B4FBB', '#FDB997'], + zIndex: 1045, + gravity: 1.5, + }); + }, + }, +}; +</script> + +<template> + <canvas ref="canvas" width="0" height="0"></canvas> +</template> diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index cd0b413265b..cf4f434a7a8 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -1,5 +1,6 @@ <script> import { + GlAlert, GlFormGroup, GlModal, GlDropdown, @@ -11,29 +12,34 @@ import { GlFormInput, GlFormCheckboxGroup, } from '@gitlab/ui'; -import { partition, isString, unescape } from 'lodash'; +import { partition, isString, unescape, uniqueId } from 'lodash'; import Api from '~/api'; import ExperimentTracking from '~/experimentation/experiment_tracking'; import { sanitize } from '~/lib/dompurify'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; -import { s__, sprintf } from '~/locale'; +import { getParameterValues } from '~/lib/utils/url_utility'; +import { sprintf } from '~/locale'; import { INVITE_MEMBERS_IN_COMMENT, GROUP_FILTERS, USERS_FILTER_ALL, MEMBER_AREAS_OF_FOCUS, + INVITE_MEMBERS_FOR_TASK, + MODAL_LABELS, } from '../constants'; import eventHub from '../event_hub'; import { responseMessageFromError, responseMessageFromSuccess, } from '../utils/response_message_parser'; +import ModalConfetti from './confetti.vue'; import GroupSelect from './group_select.vue'; import MembersTokenSelect from './members_token_select.vue'; export default { name: 'InviteMembersModal', components: { + GlAlert, GlFormGroup, GlDatepicker, GlLink, @@ -46,7 +52,9 @@ export default { GlFormCheckboxGroup, MembersTokenSelect, GroupSelect, + ModalConfetti, }, + inject: ['newProjectPath'], props: { id: { type: String, @@ -100,36 +108,54 @@ export default { type: Array, required: true, }, + tasksToBeDoneOptions: { + type: Array, + required: true, + }, + projects: { + type: Array, + required: true, + }, }, data() { return { visible: true, - modalId: 'invite-members-modal', + modalId: uniqueId('invite-members-modal-'), selectedAccessLevel: this.defaultAccessLevel, inviteeType: 'members', newUsersToInvite: [], selectedDate: undefined, selectedAreasOfFocus: [], + selectedTasksToBeDone: [], + selectedTaskProject: this.projects[0], groupToBeSharedWith: {}, source: 'unknown', invalidFeedbackMessage: '', isLoading: false, + mode: 'default', }; }, computed: { + isCelebration() { + return this.mode === 'celebrate'; + }, validationState() { return this.invalidFeedbackMessage === '' ? null : false; }, isInviteGroup() { return this.inviteeType === 'group'; }, + modalTitle() { + return this.$options.labels[this.inviteeType].modal[this.mode].title; + }, introText() { - const inviteTo = this.isProject ? 'toProject' : 'toGroup'; - - return sprintf(this.$options.labels[this.inviteeType][inviteTo].introText, { + return sprintf(this.$options.labels[this.inviteeType][this.inviteTo][this.mode].introText, { name: this.name, }); }, + inviteTo() { + return this.isProject ? 'toProject' : 'toGroup'; + }, toastOptions() { return { onComplete: () => { @@ -156,7 +182,7 @@ export default { ); }, areasOfFocusEnabled() { - return this.areasOfFocusOptions.length !== 0; + return !this.tasksToBeDoneEnabled && this.areasOfFocusOptions.length !== 0; }, areasOfFocusForPost() { if (this.selectedAreasOfFocus.length === 0 && this.areasOfFocusEnabled) { @@ -172,12 +198,40 @@ export default { return this.$options.labels[this.inviteeType].placeHolder; }, + tasksToBeDoneEnabled() { + return ( + getParameterValues('open_modal')[0] === 'invite_members_for_task' && + this.tasksToBeDoneOptions.length + ); + }, + showTasksToBeDone() { + return ( + this.tasksToBeDoneEnabled && + this.selectedAccessLevel >= INVITE_MEMBERS_FOR_TASK.minimum_access_level + ); + }, + showTaskProjects() { + return !this.isProject && this.selectedTasksToBeDone.length; + }, + tasksToBeDoneForPost() { + return this.showTasksToBeDone ? this.selectedTasksToBeDone : []; + }, + tasksProjectForPost() { + return this.showTasksToBeDone && this.selectedTasksToBeDone.length + ? this.selectedTaskProject.id + : ''; + }, }, mounted() { eventHub.$on('openModal', (options) => { this.openModal(options); this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.view); }); + + if (this.tasksToBeDoneEnabled) { + this.openModal({ inviteeType: 'members', source: 'in_product_marketing_email' }); + this.trackEvent(INVITE_MEMBERS_FOR_TASK.name, INVITE_MEMBERS_FOR_TASK.view); + } }, methods: { partitionNewUsersToInvite() { @@ -191,7 +245,8 @@ export default { usersToAddById.map((user) => user.id).join(','), ]; }, - openModal({ inviteeType, source }) { + openModal({ mode = 'default', inviteeType, source }) { + this.mode = mode; this.inviteeType = inviteeType; this.source = source; @@ -219,6 +274,12 @@ export default { this.trackEvent(MEMBER_AREAS_OF_FOCUS.name, MEMBER_AREAS_OF_FOCUS.submit); }, + trackinviteMembersForTask() { + const label = 'selected_tasks_to_be_done'; + const property = this.selectedTasksToBeDone.join(','); + const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property }); + tracking.event(INVITE_MEMBERS_FOR_TASK.submit); + }, resetFields() { this.isLoading = false; this.selectedAccessLevel = this.defaultAccessLevel; @@ -227,10 +288,15 @@ export default { this.groupToBeSharedWith = {}; this.invalidFeedbackMessage = ''; this.selectedAreasOfFocus = []; + this.selectedTasksToBeDone = []; + [this.selectedTaskProject] = this.projects; }, changeSelectedItem(item) { this.selectedAccessLevel = item; }, + changeSelectedTaskProject(project) { + this.selectedTaskProject = project; + }, submitShareWithGroup() { const apiShareWithGroup = this.isProject ? Api.projectShareWithGroup.bind(Api) @@ -263,6 +329,7 @@ export default { promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById))); } this.trackInvite(); + this.trackinviteMembersForTask(); Promise.all(promises) .then(this.conditionallyShowToastSuccess) @@ -275,6 +342,8 @@ export default { access_level: this.selectedAccessLevel, invite_source: this.source, areas_of_focus: this.areasOfFocusForPost, + tasks_to_be_done: this.tasksToBeDoneForPost, + tasks_project_id: this.tasksProjectForPost, }; }, addByUserIdPostData(usersToAddById) { @@ -284,6 +353,8 @@ export default { access_level: this.selectedAccessLevel, invite_source: this.source, areas_of_focus: this.areasOfFocusForPost, + tasks_to_be_done: this.tasksToBeDoneForPost, + tasks_project_id: this.tasksProjectForPost, }; }, shareWithGroupPostData(groupToBeSharedWith) { @@ -322,49 +393,7 @@ export default { return unescape(sanitize(message, { ALLOWED_TAGS: [] })); }, }, - labels: { - members: { - modalTitle: s__('InviteMembersModal|Invite members'), - searchField: s__('InviteMembersModal|GitLab member or email address'), - placeHolder: s__('InviteMembersModal|Select members or type email addresses'), - toGroup: { - introText: s__( - "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} group.", - ), - }, - toProject: { - introText: s__( - "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} project.", - ), - }, - }, - group: { - modalTitle: s__('InviteMembersModal|Invite a group'), - searchField: s__('InviteMembersModal|Select a group to invite'), - placeHolder: s__('InviteMembersModal|Search for a group to invite'), - toGroup: { - introText: s__( - "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} group.", - ), - }, - toProject: { - introText: s__( - "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} project.", - ), - }, - }, - accessLevel: s__('InviteMembersModal|Select a role'), - accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'), - toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'), - invalidFeedbackMessageDefault: s__('InviteMembersModal|Something went wrong'), - readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`), - inviteButtonText: s__('InviteMembersModal|Invite'), - cancelButtonText: s__('InviteMembersModal|Cancel'), - headerCloseLabel: s__('InviteMembersModal|Close invite team members'), - areasOfFocusLabel: s__( - 'InviteMembersModal|What would you like new member(s) to focus on? (optional)', - ), - }, + labels: MODAL_LABELS, membersTokenSelectLabelId: 'invite-members-input', }; </script> @@ -374,20 +403,29 @@ export default { :modal-id="modalId" size="sm" data-qa-selector="invite_members_modal_content" - :title="$options.labels[inviteeType].modalTitle" + data-testid="invite-members-modal" + :title="modalTitle" :header-close-label="$options.labels.headerCloseLabel" @hidden="resetFields" @close="resetFields" @hide="resetFields" > <div> - <p ref="introText"> - <gl-sprintf :message="introText"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </p> + <div class="gl-display-flex"> + <div v-if="isCelebration" class="gl-p-4 gl-font-size-h1"><gl-emoji data-name="tada" /></div> + <div> + <p ref="introText"> + <gl-sprintf :message="introText"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + <br /> + <span v-if="isCelebration">{{ $options.labels.members.modal.celebrate.intro }} </span> + <modal-confetti v-if="isCelebration" /> + </p> + </div> + </div> <gl-form-group :invalid-feedback="invalidFeedbackMessage" @@ -476,24 +514,70 @@ export default { data-testid="area-of-focus-checks" /> </div> + <div v-if="showTasksToBeDone" data-testid="invite-members-modal-tasks-to-be-done"> + <label class="gl-mt-5"> + {{ $options.labels.members.tasksToBeDone.title }} + </label> + <template v-if="projects.length"> + <gl-form-checkbox-group + v-model="selectedTasksToBeDone" + :options="tasksToBeDoneOptions" + data-testid="invite-members-modal-tasks" + /> + <template v-if="showTaskProjects"> + <label class="gl-mt-5 gl-display-block"> + {{ $options.labels.members.tasksProject.title }} + </label> + <gl-dropdown + class="gl-w-half gl-xs-w-full" + :text="selectedTaskProject.title" + data-testid="invite-members-modal-project-select" + > + <template v-for="project in projects"> + <gl-dropdown-item + :key="project.id" + active-class="is-active" + is-check-item + :is-checked="project.id === selectedTaskProject.id" + @click="changeSelectedTaskProject(project)" + > + {{ project.title }} + </gl-dropdown-item> + </template> + </gl-dropdown> + </template> + </template> + <gl-alert + v-else-if="tasksToBeDoneEnabled" + variant="tip" + :dismissible="false" + data-testid="invite-members-modal-no-projects-alert" + > + <gl-sprintf :message="$options.labels.members.tasksToBeDone.noProjects"> + <template #link="{ content }"> + <gl-link :href="newProjectPath" target="_blank" class="gl-label-link"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> + </div> </div> <template #modal-footer> - <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0"> - <gl-button data-testid="cancel-button" @click="closeModal"> - {{ $options.labels.cancelButtonText }} - </gl-button> - <div class="gl-mr-3"></div> - <gl-button - :disabled="inviteDisabled" - :loading="isLoading" - variant="success" - data-qa-selector="invite_button" - data-testid="invite-button" - @click="sendInvite" - >{{ $options.labels.inviteButtonText }}</gl-button - > - </div> + <gl-button data-testid="cancel-button" @click="closeModal"> + {{ $options.labels.cancelButtonText }} + </gl-button> + <gl-button + :disabled="inviteDisabled" + :loading="isLoading" + variant="success" + data-qa-selector="invite_button" + data-testid="invite-button" + @click="sendInvite" + > + {{ $options.labels.inviteButtonText }} + </gl-button> </template> </gl-modal> </template> diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue index 05be427742c..bf3250f63a5 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue @@ -1,11 +1,12 @@ <script> -import { GlButton, GlLink } from '@gitlab/ui'; +import { GlButton, GlLink, GlIcon } from '@gitlab/ui'; import ExperimentTracking from '~/experimentation/experiment_tracking'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; +import { TRIGGER_ELEMENT_BUTTON, TRIGGER_ELEMENT_SIDE_NAV } from '../constants'; export default { - components: { GlButton, GlLink }, + components: { GlButton, GlLink, GlIcon }, props: { displayText: { type: String, @@ -53,13 +54,11 @@ export default { }, }, computed: { - isButton() { - return this.triggerElement === 'button'; - }, componentAttributes() { const baseAttributes = { class: this.classes, 'data-qa-selector': 'invite_members_button', + 'data-test-id': 'invite-members-button', }; if (this.event && this.label) { @@ -77,6 +76,9 @@ export default { this.trackExperimentOnShow(); }, methods: { + checkTrigger(targetTriggerElement) { + return this.triggerElement === targetTriggerElement; + }, openModal() { eventHub.$emit('openModal', { inviteeType: 'members', source: this.triggerSource }); }, @@ -87,12 +89,14 @@ export default { } }, }, + TRIGGER_ELEMENT_BUTTON, + TRIGGER_ELEMENT_SIDE_NAV, }; </script> <template> <gl-button - v-if="isButton" + v-if="checkTrigger($options.TRIGGER_ELEMENT_BUTTON)" v-bind="componentAttributes" :variant="variant" :icon="icon" @@ -100,6 +104,17 @@ export default { > {{ displayText }} </gl-button> + <gl-link + v-else-if="checkTrigger($options.TRIGGER_ELEMENT_SIDE_NAV)" + v-bind="componentAttributes" + data-is-link="true" + @click="openModal" + > + <span class="nav-icon-container"> + <gl-icon :name="icon" /> + </span> + <span class="nav-item-name"> {{ displayText }} </span> + </gl-link> <gl-link v-else v-bind="componentAttributes" data-is-link="true" @click="openModal"> {{ displayText }} </gl-link> diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index d7daf83e26b..59d4c2f3077 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -1,4 +1,4 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; export const SEARCH_DELAY = 200; @@ -8,6 +8,12 @@ export const MEMBER_AREAS_OF_FOCUS = { view: 'view', submit: 'submit', }; +export const INVITE_MEMBERS_FOR_TASK = { + minimum_access_level: 30, + name: 'invite_members_for_task', + view: 'modal_opened_from_email', + submit: 'submit', +}; export const GROUP_FILTERS = { ALL: 'all', @@ -19,3 +25,122 @@ export const API_MESSAGES = { }; export const USERS_FILTER_ALL = 'all'; export const USERS_FILTER_SAML_PROVIDER_ID = 'saml_provider_id'; +export const TRIGGER_ELEMENT_BUTTON = 'button'; +export const TRIGGER_ELEMENT_SIDE_NAV = 'side-nav'; +export const MEMBERS_MODAL_DEFAULT_TITLE = s__('InviteMembersModal|Invite members'); +export const MEMBERS_MODAL_CELEBRATE_TITLE = s__( + 'InviteMembersModal|GitLab is better with colleagues!', +); +export const MEMBERS_MODAL_CELEBRATE_INTRO = s__( + 'InviteMembersModal|How about inviting a colleague or two to join you?', +); +export const MEMBERS_TO_GROUP_DEFAULT_INTRO_TEXT = s__( + "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} group.", +); + +export const MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT = s__( + "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} project.", +); +export const MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT = s__( + "InviteMembersModal|Congratulations on creating your project, you're almost there!", +); +export const MEMBERS_SEARCH_FIELD = s__('InviteMembersModal|GitLab member or email address'); +export const MEMBERS_PLACEHOLDER = s__('InviteMembersModal|Select members or type email addresses'); +export const MEMBERS_TASKS_TO_BE_DONE_TITLE = s__( + 'InviteMembersModal|Create issues for your new team member to work on (optional)', +); +export const MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS = s__( + 'InviteMembersModal|To assign issues to a new team member, you need a project for the issues. %{linkStart}Create a project to get started.%{linkEnd}', +); +export const MEMBERS_TASKS_PROJECTS_TITLE = s__( + 'InviteMembersModal|Choose a project for the issues', +); + +export const GROUP_MODAL_DEFAULT_TITLE = s__('InviteMembersModal|Invite a group'); +export const GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT = s__( + "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} group.", +); +export const GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT = s__( + "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} project.", +); + +export const GROUP_SEARCH_FIELD = s__('InviteMembersModal|Select a group to invite'); +export const GROUP_PLACEHOLDER = s__('InviteMembersModal|Search for a group to invite'); + +export const ACCESS_LEVEL = s__('InviteMembersModal|Select a role'); +export const ACCESS_EXPIRE_DATE = s__('InviteMembersModal|Access expiration date (optional)'); +export const TOAST_MESSAGE_SUCCESSFUL = s__('InviteMembersModal|Members were successfully added'); +export const INVALID_FEEDBACK_MESSAGE_DEFAULT = s__('InviteMembersModal|Something went wrong'); +export const READ_MORE_TEXT = s__( + `InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`, +); +export const INVITE_BUTTON_TEXT = s__('InviteMembersModal|Invite'); +export const CANCEL_BUTTON_TEXT = s__('InviteMembersModal|Cancel'); +export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team members'); +export const AREAS_OF_FOCUS_LABEL = s__( + 'InviteMembersModal|What would you like new member(s) to focus on? (optional)', +); + +export const MODAL_LABELS = { + members: { + modal: { + default: { + title: MEMBERS_MODAL_DEFAULT_TITLE, + }, + celebrate: { + title: MEMBERS_MODAL_CELEBRATE_TITLE, + intro: MEMBERS_MODAL_CELEBRATE_INTRO, + }, + }, + toGroup: { + default: { + introText: MEMBERS_TO_GROUP_DEFAULT_INTRO_TEXT, + }, + }, + toProject: { + default: { + introText: MEMBERS_TO_PROJECT_DEFAULT_INTRO_TEXT, + }, + celebrate: { + introText: MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT, + }, + }, + searchField: MEMBERS_SEARCH_FIELD, + placeHolder: MEMBERS_PLACEHOLDER, + tasksToBeDone: { + title: MEMBERS_TASKS_TO_BE_DONE_TITLE, + noProjects: MEMBERS_TASKS_TO_BE_DONE_NO_PROJECTS, + }, + tasksProject: { + title: MEMBERS_TASKS_PROJECTS_TITLE, + }, + }, + group: { + modal: { + default: { + title: GROUP_MODAL_DEFAULT_TITLE, + }, + }, + toGroup: { + default: { + introText: GROUP_MODAL_TO_GROUP_DEFAULT_INTRO_TEXT, + }, + }, + toProject: { + default: { + introText: GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT, + }, + }, + searchField: GROUP_SEARCH_FIELD, + placeHolder: GROUP_PLACEHOLDER, + }, + accessLevel: ACCESS_LEVEL, + accessExpireDate: ACCESS_EXPIRE_DATE, + toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL, + invalidFeedbackMessageDefault: INVALID_FEEDBACK_MESSAGE_DEFAULT, + readMoreText: READ_MORE_TEXT, + inviteButtonText: INVITE_BUTTON_TEXT, + cancelButtonText: CANCEL_BUTTON_TEXT, + headerCloseLabel: HEADER_CLOSE_LABEL, + areasOfFocusLabel: AREAS_OF_FOCUS_LABEL, +}; diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index c1dfaa25dc7..fc657a064dd 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -5,15 +5,32 @@ import { parseBoolean } from '~/lib/utils/common_utils'; Vue.use(GlToast); +let initedInviteMembersModal; + export default function initInviteMembersModal() { + if (initedInviteMembersModal) { + // if we already loaded this in another part of the dom, we don't want to do it again + // else we will stack the modals + return false; + } + + // https://gitlab.com/gitlab-org/gitlab/-/issues/344955 + // bug lying in wait here for someone to put group and project invite in same screen + // once that happens we'll need to mount these differently, perhaps split + // group/project to each mount one, with many ways to open it. const el = document.querySelector('.js-invite-members-modal'); if (!el) { return false; } + initedInviteMembersModal = true; + return new Vue({ el, + provide: { + newProjectPath: el.dataset.newProjectPath, + }, render: (createElement) => createElement(InviteMembersModal, { props: { @@ -24,6 +41,8 @@ export default function initInviteMembersModal() { groupSelectFilter: el.dataset.groupsFilter, groupSelectParentId: parseInt(el.dataset.parentId, 10), areasOfFocusOptions: JSON.parse(el.dataset.areasOfFocusOptions), + tasksToBeDoneOptions: JSON.parse(el.dataset.tasksToBeDoneOptions || '[]'), + projects: JSON.parse(el.dataset.projects || '[]'), noSelectionAreasOfFocus: JSON.parse(el.dataset.noSelectionAreasOfFocus), usersFilter: el.dataset.usersFilter, filterId: parseInt(el.dataset.filterId, 10), |