From d329db23ff35b121aafeb4068c183a776660fefa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 4 Mar 2021 21:47:25 +0100 Subject: [PATCH] Finish user invite modal (#9061) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix some ium stylings * Extend create service to also test with empty names * Add PrincipalLike type to pass around non-created placeholder refs * Add icon-context * Move principal rendering to its own module * Fix emit create new placeholder to principals * Revert op-principal for now * Add memberships form API to properly post * Fix types for returned principals * Move untilDestroyed in role * Filter input if not string in role-search * Pass correct inputs to success component * Return principal after saving membership * Fix small stuff around the ium * Fix the way HalResources are selected and passed * Move principal module to be exported by common * Disable quotemark in tslint until eslint is enabled * Fix image path in success * Adapt modal to run all steps in one within the modal helper component * Several fixes to modals * Fix ium success component styles, * Registration modal y-overflow * Add SMTP parameters to .env.example * Add disabled option to op-option-list, disabled placeholder users for non-ee instances * Add correct ee link to placeholder user option * Fix build * Removed unused sass files * Fix principal search not found indicator, added placeholder add image * Fix enterprise edition url, use dirty instead of touched check * Use backend class names for frontend principal types * Fix duplicate import and principal type usage * Also disable banners if with_ee is present in test * Extend specs for placeholders * Fix disabled attribute * Extend spec WIP * Improved inline-validation styles, fixed more PrincipalType usages * Add group happy path test, fix more PrincipalType usage * Fix a translation * Revert line deletion * Rewrite same spec examples into shared examples * Fix name of shared example * Dont run assets:clean to remove angular assets * Output whether assets are there at all * Fix placeholder path * Revert "Output whether assets are there at all" This reverts commit 42219c27556c0eb155c7fcf1606345da6a819cc2. Co-authored-by: Benjamin Bädorf --- .codeclimate.yml | 3 + .env.example | 8 + app/views/account/_footer.html.erb | 4 +- app/views/account/_register.html.erb | 5 +- app/views/account/register.html.erb | 2 +- config/locales/js-en.yml | 20 ++- docker-compose.yml | 2 + .../enterprise-banner.component.ts | 14 +- frontend/src/app/globals/constants.const.ts | 2 +- .../memberships/apiv3-memberships-form.ts | 64 +++++++ .../memberships/apiv3-memberships-paths.ts | 19 ++- .../create-autocompleter.component.html | 3 +- .../index-page/boards-index-page.component.ts | 4 +- .../common/enterprise/banners.service.ts | 14 ++ .../form-field/form-binding.directive.ts | 11 +- .../form-field/form-field.component.sass | 41 ----- .../common/form-field/form-field.component.ts | 22 ++- .../modules/common/form-field/form-field.sass | 2 +- .../modules/common/modal/modal.component.sass | 26 --- .../common/openproject-common.module.sass | 1 - .../common/openproject-common.module.ts | 6 +- .../option-list/option-list.component.html | 6 +- .../option-list/option-list.component.sass | 36 ---- .../option-list/option-list.component.ts | 9 + .../common/option-list/option-list.sass | 3 + .../widgets/members/members.component.ts | 3 +- .../hal/resources/membership-resource.ts | 2 +- .../invite-user-modal.types.ts | 3 + .../invite-user.component.html | 5 +- .../invite-user.component.ts | 19 ++- .../message/message.component.ts | 8 +- .../principal/principal-search.component.html | 49 +++--- .../principal/principal-search.component.ts | 60 +++---- .../principal/principal.component.html | 24 +-- .../principal/principal.component.ts | 56 +++--- .../project-selection.component.html | 2 +- .../project-selection.component.ts | 40 +++-- .../role/role-search.component.ts | 4 +- .../invite-user-modal/role/role.component.ts | 10 +- .../success/success.component.html | 18 +- .../success/success.component.sass | 9 + .../success/success.component.ts | 18 +- .../summary/summary.component.html | 7 +- .../summary/summary.component.ts | 89 ++++++---- .../app/modules/modal/modal-overrides.sass | 7 + .../src/app/modules/modal/modal.module.sass | 1 + frontend/src/app/modules/modal/modal.sass | 14 +- .../principal/principal-helper.ts | 0 .../principal/principal-renderer.service.ts | 3 +- .../principal/principal-rendering.module.ts | 17 ++ .../principal.component.ts} | 5 +- .../invite-user-modal/placeholder-added.svg | 4 + .../invite-user-modal/successful-invite.svg | 4 + spec/features/users/invite_user_modal_spec.rb | 161 +++++++++++++----- .../users/create_user_service_spec.rb | 8 + spec/support/components/common/modal.rb | 6 + .../components/users/invite_user_modal.rb | 123 ++++++++++++- spec/support/shared/with_ee.rb | 3 + 58 files changed, 723 insertions(+), 386 deletions(-) create mode 100644 frontend/src/app/modules/apiv3/endpoints/memberships/apiv3-memberships-form.ts delete mode 100644 frontend/src/app/modules/common/form-field/form-field.component.sass delete mode 100644 frontend/src/app/modules/common/modal/modal.component.sass delete mode 100644 frontend/src/app/modules/common/option-list/option-list.component.sass create mode 100644 frontend/src/app/modules/invite-user-modal/invite-user-modal.types.ts create mode 100644 frontend/src/app/modules/modal/modal-overrides.sass rename frontend/src/app/modules/{common => }/principal/principal-helper.ts (100%) rename frontend/src/app/modules/{common => }/principal/principal-renderer.service.ts (97%) create mode 100644 frontend/src/app/modules/principal/principal-rendering.module.ts rename frontend/src/app/modules/{common/principal/op-principal.component.ts => principal/principal.component.ts} (94%) create mode 100644 frontend/src/assets/images/invite-user-modal/placeholder-added.svg create mode 100644 frontend/src/assets/images/invite-user-modal/successful-invite.svg diff --git a/.codeclimate.yml b/.codeclimate.yml index 4bb3812073c..7e782aaa4dc 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -51,6 +51,9 @@ plugins: # Disable whitespace due to 3.7 incompatibiltiy with ? navigation whitespace: enabled: false + # Disable difference in import styles until we migrate to eslint + quotemark: + enabled: false fixme: enabled: true ratings: diff --git a/.env.example b/.env.example index 19b45fc1bb5..8550cb9623b 100644 --- a/.env.example +++ b/.env.example @@ -47,3 +47,11 @@ DB_PORT=5432 DB_USERNAME=openproject DB_PASSWORD=openproject DB_DATABASE=openproject + +CI_JOBS=24 + +SMTP_ADDRESS= +SMTP_PORT= +SMTP_DOMAIN= +SMTP_USER_NAME= +SMTP_PASSWORD= diff --git a/app/views/account/_footer.html.erb b/app/views/account/_footer.html.erb index d5c70ac5287..d3a42b3dba9 100644 --- a/app/views/account/_footer.html.erb +++ b/app/views/account/_footer.html.erb @@ -1,3 +1,5 @@ <% if footer = registration_footer %> - <%= footer %> + <% end %> diff --git a/app/views/account/_register.html.erb b/app/views/account/_register.html.erb index 2f9d0ef0920..ff5a29a100a 100644 --- a/app/views/account/_register.html.erb +++ b/app/views/account/_register.html.erb @@ -112,8 +112,7 @@ See docs/COPYRIGHT.rdoc for more details. <%= render partial: 'account/auth_providers', locals: { omniauth_title: I18n.t('account.signup_with_auth_provider'), wide: true } %> - + + <%= render partial: 'account/footer' %> <% end %> diff --git a/app/views/account/register.html.erb b/app/views/account/register.html.erb index 0cfbf54831d..b5877ed92dd 100644 --- a/app/views/account/register.html.erb +++ b/app/views/account/register.html.erb @@ -29,7 +29,7 @@ See docs/COPYRIGHT.rdoc for more details.
+ data-modal-class-name="registration-modal -highlight"> <% @user ||= User.new %> <%= render partial: '/account/register' %>
diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index ca5b362d6a4..970a5f7e213 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -1040,9 +1040,9 @@ en: title: invite: 'Invite user' invite_to_project: 'Invite %{type} to %{project}' - user: 'user' - group: 'group' - placeholder: 'placeholder user' + User: 'user' + Group: 'group' + PlaceholderUser: 'placeholder user' invite_principal_to_project: 'Invite %{principal} to %{project}' project: label: 'Project' @@ -1058,8 +1058,11 @@ en: title: 'Group' description: 'Permissions based on the assigned role in the selected project' placeholder: - title: 'Placeholder' - description: 'Has no access to the project and no emails are sent out' + title: 'Placeholder user' + title_no_ee: 'Placeholder user (Enterprise Edition only feature)' + description: 'Has no access to the project and no emails are sent out.' + description_no_ee: 'Has no access to the project and no emails are sent out. +
Check out the Enterprise Edition' principal: label: @@ -1068,15 +1071,14 @@ en: already_member_message: 'Already a member of %{project}' no_results_user: 'No users were found' - invite_user: 'Invite: %{email}' + invite_user: 'Invite:' change_user_selection: 'Change e-mail-address or select an existing user' no_results_placeholder: 'No placeholders were found' change_placeholder_selection: 'Change name or select an existing placeholder' - create_new_placeholder: 'Create new placeholder: %{name}' + create_new_placeholder: 'Create new placeholder:' no_results_group: 'No groups were found' - create_new_group: 'Create new group: %{name}' change_group_selection: 'Change name or select an existing group' next_button: 'Next' @@ -1108,7 +1110,7 @@ en: success: title: '%{principal} was invited!' description: - user: 'The user can now login to access %{project}. Meanwhile you can already plan with that user and assign work packages for instance.' + user: 'The user can now log in to access %{project}. Meanwhile you can already plan with that user and assign work packages for instance.' placeholder: 'The placeholder can now be used in %{project}. Meanwhile you can already plan with that user and assign work packages for instance.' group: 'The group is now a part of %{project}. Meanwhile you can already plan with that group and assign work packages for instance.' next_button: 'Continue' diff --git a/docker-compose.yml b/docker-compose.yml index 9b3d69520a2..74dd1077731 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -103,6 +103,8 @@ services: stop_grace_period: "3s" volumes: - "pgdata-test:/var/lib/postgresql/data" + ports: + - "5432:5432" environment: POSTGRES_DB: openproject POSTGRES_USER: openproject diff --git a/frontend/src/app/components/enterprise-banner/enterprise-banner.component.ts b/frontend/src/app/components/enterprise-banner/enterprise-banner.component.ts index b8f6dd4106c..b5bbcf2c2ef 100644 --- a/frontend/src/app/components/enterprise-banner/enterprise-banner.component.ts +++ b/frontend/src/app/components/enterprise-banner/enterprise-banner.component.ts @@ -1,5 +1,5 @@ import {Component, Input} from "@angular/core"; -import {enterpriseEditionUrl} from "core-app/globals/constants.const"; +import {BannersService} from "core-app/modules/common/enterprise/banners.service"; import {I18nService} from "core-app/modules/common/i18n/i18n.service"; @Component({ @@ -28,14 +28,12 @@ export class EnterpriseBannerComponent { enterpriseFeature: this.I18n.t('js.upsale.ee_only'), }; - constructor(protected I18n:I18nService) { - } + constructor( + protected I18n:I18nService, + protected bannersService:BannersService, + ) {} public eeLink() { - if (this.opReferrer) { - return enterpriseEditionUrl + '&op_referrer=' + this.opReferrer; - } else { - return enterpriseEditionUrl; - } + this.bannersService.getEnterPriseEditionUrl({ referrer: this.opReferrer }); } } diff --git a/frontend/src/app/globals/constants.const.ts b/frontend/src/app/globals/constants.const.ts index df2ca183472..d80a3c3c6a4 100644 --- a/frontend/src/app/globals/constants.const.ts +++ b/frontend/src/app/globals/constants.const.ts @@ -1,3 +1,3 @@ -export const enterpriseEditionUrl = "https://www.openproject.org/enterprise-edition/?op_edtion=community-edition"; +export const enterpriseEditionUrl = "https://www.openproject.org/enterprise-edition/?op_edition=community-edition"; export const enterpriseDemoUrl = "https://www.openproject.org/enterprise-demo/"; diff --git a/frontend/src/app/modules/apiv3/endpoints/memberships/apiv3-memberships-form.ts b/frontend/src/app/modules/apiv3/endpoints/memberships/apiv3-memberships-form.ts new file mode 100644 index 00000000000..59208591e46 --- /dev/null +++ b/frontend/src/app/modules/apiv3/endpoints/memberships/apiv3-memberships-form.ts @@ -0,0 +1,64 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) 2012-2021 the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See docs/COPYRIGHT.rdoc for more details. +//++ + +import {APIv3FormResource} from "core-app/modules/apiv3/forms/apiv3-form-resource"; +import {SchemaResource} from "core-app/modules/hal/resources/schema-resource"; +import {HalPayloadHelper} from "core-app/modules/hal/schemas/hal-payload.helper"; +import {HalResource} from "core-app/modules/hal/resources/hal-resource"; +import {MembershipResource, MembershipResourceEmbedded} from "core-app/modules/hal/resources/membership-resource"; + +export class Apiv3MembershipsForm extends APIv3FormResource { + + /** + * We need to override the grid widget extraction + * to pass the correct payload to the API. + * + * @param resource + * @param schema + */ + public static extractPayload(resource:MembershipResourceEmbedded):Object { + return { + _links: { + project: { href: resource.project.href }, + principal: { href: resource.principal.href }, + roles: resource.roles.map(role => ({ href: role.href })), + } + } + } + + /** + * Extract payload for the form from the request and optional schema. + * + * @param request + * @param schema + */ + public extractPayload(request:MembershipResourceEmbedded) { + return Apiv3MembershipsForm.extractPayload(request); + } + +} diff --git a/frontend/src/app/modules/apiv3/endpoints/memberships/apiv3-memberships-paths.ts b/frontend/src/app/modules/apiv3/endpoints/memberships/apiv3-memberships-paths.ts index b24fdf9c250..037640d8ace 100644 --- a/frontend/src/app/modules/apiv3/endpoints/memberships/apiv3-memberships-paths.ts +++ b/frontend/src/app/modules/apiv3/endpoints/memberships/apiv3-memberships-paths.ts @@ -36,17 +36,24 @@ import { import {Observable} from "rxjs"; import {HalResource} from "core-app/modules/hal/resources/hal-resource"; import {CollectionResource} from "core-app/modules/hal/resources/collection-resource"; -import {MembershipResource} from "core-app/modules/hal/resources/membership-resource"; +import {MembershipResource, MembershipResourceEmbedded} from "core-app/modules/hal/resources/membership-resource"; import {ProjectResource} from 'core-app/modules/hal/resources/project-resource'; import {UserResource} from "core-app/modules/hal/resources/user-resource"; import {GroupResource} from "core-app/modules/hal/resources/group-resource"; import {PlaceholderUserResource} from "core-app/modules/hal/resources/placeholder-user-resource"; import {RoleResource} from 'core-app/modules/hal/resources/role-resource'; +import {Apiv3MembershipsForm} from "core-app/modules/apiv3/endpoints/memberships/apiv3-memberships-form"; +import {SchemaResource} from "core-app/modules/hal/resources/schema-resource"; +import {map, switchMap} from "rxjs/operators"; export class Apiv3MembershipsPaths extends APIv3ResourceCollection> implements Apiv3ListResourceInterface { + + // Static paths + readonly form = this.subResource('form', Apiv3MembershipsForm); + constructor(protected apiRoot:APIV3Service, protected basePath:string) { super(apiRoot, basePath, 'memberships'); @@ -71,16 +78,14 @@ export class Apiv3MembershipsPaths * * @param resource */ - public post(resource:{ - principal:UserResource|GroupResource|PlaceholderUserResource, - project:ProjectResource, - roles:RoleResource[], - }):Observable { + public post(resource:MembershipResourceEmbedded):Observable { + const payload = this.form.extractPayload(resource); return this .halResourceService .post( this.path, - resource, + payload, ); } + } diff --git a/frontend/src/app/modules/autocompleter/create-autocompleter/create-autocompleter.component.html b/frontend/src/app/modules/autocompleter/create-autocompleter/create-autocompleter.component.html index fc0a489d237..90bea786a3f 100644 --- a/frontend/src/app/modules/autocompleter/create-autocompleter/create-autocompleter.component.html +++ b/frontend/src/app/modules/autocompleter/create-autocompleter/create-autocompleter.component.html @@ -27,7 +27,6 @@ - - + \ No newline at end of file diff --git a/frontend/src/app/modules/boards/index-page/boards-index-page.component.ts b/frontend/src/app/modules/boards/index-page/boards-index-page.component.ts index 5daae5a0c43..aa9bac24894 100644 --- a/frontend/src/app/modules/boards/index-page/boards-index-page.component.ts +++ b/frontend/src/app/modules/boards/index-page/boards-index-page.component.ts @@ -9,7 +9,7 @@ import {NewBoardModalComponent} from "core-app/modules/boards/new-board-modal/ne import {BannersService} from "core-app/modules/common/enterprise/banners.service"; import {LoadingIndicatorService} from "core-app/modules/common/loading-indicator/loading-indicator.service"; import {AuthorisationService} from "core-app/modules/common/model-auth/model-auth.service"; -import {enterpriseDemoUrl, enterpriseEditionUrl} from "core-app/globals/constants.const"; +import {enterpriseDemoUrl} from "core-app/globals/constants.const"; import {DomSanitizer} from "@angular/platform-browser"; import {boardTeaserVideoURL} from "core-app/modules/boards/board-constants.const"; import {UntilDestroyedMixin} from "core-app/helpers/angular/until-destroyed.mixin"; @@ -99,7 +99,7 @@ export class BoardsIndexPageComponent extends UntilDestroyedMixin implements OnI } public eeLink() { - return enterpriseEditionUrl + '&op_referrer=boards'; + return this.bannerService.getEnterPriseEditionUrl({ referrer: 'boards' }); } public demoLink() { diff --git a/frontend/src/app/modules/common/enterprise/banners.service.ts b/frontend/src/app/modules/common/enterprise/banners.service.ts index 5b4e07830e2..7bad02927cd 100644 --- a/frontend/src/app/modules/common/enterprise/banners.service.ts +++ b/frontend/src/app/modules/common/enterprise/banners.service.ts @@ -28,6 +28,7 @@ import {Inject, Injectable} from '@angular/core'; import {DOCUMENT} from "@angular/common"; +import {enterpriseEditionUrl} from "core-app/globals/constants.const"; @Injectable({ providedIn: 'root' }) export class BannersService { @@ -42,6 +43,19 @@ export class BannersService { return this._banners; } + public getEnterPriseEditionUrl({ referrer, hash }:{referrer?:string, hash?:string} = {}) { + const url = new URL(enterpriseEditionUrl); + if (referrer) { + url.searchParams.set('op_referrer', referrer); + } + + if (hash) { + url.hash = hash; + } + + return url.toString(); + } + public conditional(bannersVisible?:() => void, bannersNotVisible?:() => void) { this._banners ? this.callMaybe(bannersVisible) : this.callMaybe(bannersNotVisible); } diff --git a/frontend/src/app/modules/common/form-field/form-binding.directive.ts b/frontend/src/app/modules/common/form-field/form-binding.directive.ts index e36d40d6438..41aa1df9737 100644 --- a/frontend/src/app/modules/common/form-field/form-binding.directive.ts +++ b/frontend/src/app/modules/common/form-field/form-binding.directive.ts @@ -7,6 +7,7 @@ import { NgControl, FormControl, FormGroup, + FormArray, } from '@angular/forms'; export const formControlBinding:any = { @@ -14,11 +15,15 @@ export const formControlBinding:any = { useExisting: forwardRef(() => OpFormBindingDirective) }; -@Directive({selector: '[opFormBinding]', providers: [formControlBinding], exportAs: 'ngForm'}) +@Directive({ + selector: '[opFormBinding]', + providers: [formControlBinding], + exportAs: 'ngForm', +}) export class OpFormBindingDirective extends NgControl { - @Input('opFormBinding') form!:FormControl|FormGroup; + @Input('opFormBinding') form!:FormControl|FormGroup|FormArray; - get control():FormControl|FormGroup { + get control():FormControl|FormGroup|FormArray { return this.form; } diff --git a/frontend/src/app/modules/common/form-field/form-field.component.sass b/frontend/src/app/modules/common/form-field/form-field.component.sass deleted file mode 100644 index 4d69d381781..00000000000 --- a/frontend/src/app/modules/common/form-field/form-field.component.sass +++ /dev/null @@ -1,41 +0,0 @@ -@import './form-field.children' - -.op-form-field - display: flex - flex-direction: column - - &--label, - &--description, - &--input, - &--help-text - margin-bottom: 0.5rem - - &--label-wrap - display: flex - flex-direction: column - margin: 0 - - &--label - font-size: 14px - font-weight: bold - line-height: 1.2 - - &-indicator - color: var(--primary-color) - - &_invalid &--label - color: var(--content-form-error-color) - - &--input - - &--description, - &--help-text - font-size: 12px - - &--errors - display: flex - flex-direction: column - - &--error - color: var(--content-form-error-color) - margin-top: 1rem diff --git a/frontend/src/app/modules/common/form-field/form-field.component.ts b/frontend/src/app/modules/common/form-field/form-field.component.ts index dbb444bcb95..1646a1947b3 100644 --- a/frontend/src/app/modules/common/form-field/form-field.component.ts +++ b/frontend/src/app/modules/common/form-field/form-field.component.ts @@ -5,7 +5,8 @@ import { ContentChild, } from "@angular/core"; import { - NgControl + NgControl, + AbstractControl, } from "@angular/forms"; @Component({ @@ -23,7 +24,24 @@ export class OpFormFieldComponent { @ContentChild(NgControl) control:NgControl; + get isDirty() { + let control:AbstractControl|null = this.control?.control; + do { + if (!control) { + return false; + } + + if (control.dirty) { + return true; + } + + control = control.parent; + } while (control); + + return false; + } + get isInvalid() { - return this.control?.touched && this.control?.invalid; + return this.isDirty && this.control?.invalid; } } diff --git a/frontend/src/app/modules/common/form-field/form-field.sass b/frontend/src/app/modules/common/form-field/form-field.sass index 356b89532ec..23fa79310f6 100644 --- a/frontend/src/app/modules/common/form-field/form-field.sass +++ b/frontend/src/app/modules/common/form-field/form-field.sass @@ -36,4 +36,4 @@ &--error color: var(--content-form-error-color) - margin-top: 1rem + margin-bottom: 1rem diff --git a/frontend/src/app/modules/common/modal/modal.component.sass b/frontend/src/app/modules/common/modal/modal.component.sass deleted file mode 100644 index 999a86f0a3c..00000000000 --- a/frontend/src/app/modules/common/modal/modal.component.sass +++ /dev/null @@ -1,26 +0,0 @@ -.op-modal - display: flex - background: white - - &--heading - display: flex - padding-top: 1rem - flex-direction: column - position: relative - - &--close-button - position: absolute - right: 0px - top: 0px - height: 2rem - width: 2rem - display: flex - justify-content: center - align-items: center - cursor: pointer - color: var(--body-font-color) - background: transparent - border: 0 - &:hover - text-decoration: none - color: var(--content-link-color) diff --git a/frontend/src/app/modules/common/openproject-common.module.sass b/frontend/src/app/modules/common/openproject-common.module.sass index 5a70bbc8c21..0fcb64aad93 100644 --- a/frontend/src/app/modules/common/openproject-common.module.sass +++ b/frontend/src/app/modules/common/openproject-common.module.sass @@ -2,4 +2,3 @@ @import './form-field/form-field' @import './option-list/option-list' @import './export-options/export-options' -@import './modal/modal.component' diff --git a/frontend/src/app/modules/common/openproject-common.module.ts b/frontend/src/app/modules/common/openproject-common.module.ts index 6684c482441..9de5ab9b092 100644 --- a/frontend/src/app/modules/common/openproject-common.module.ts +++ b/frontend/src/app/modules/common/openproject-common.module.ts @@ -79,11 +79,11 @@ import {AutofocusDirective} from './autofocus/autofocus.directive'; import {ShowSectionDropdownComponent} from './hide-section/show-section-dropdown.component'; import {SlideToggleComponent} from './slide-toggle/slide-toggle.component'; import {DynamicBootstrapModule} from './dynamic-bootstrap/dynamic-bootstrap.module'; -import {OpPrincipalComponent} from './principal/op-principal.component'; import {OpFormFieldComponent} from './form-field/form-field.component'; import {OpFormBindingDirective} from './form-field/form-binding.directive'; import {OpOptionListComponent} from './option-list/option-list.component'; import {OpIconComponent} from './icon/icon.component'; +import {OpenprojectPrincipalRenderingModule} from "core-app/modules/principal/principal-rendering.module"; export function bootstrapModule(injector:Injector) { // Ensure error reporter is run @@ -129,6 +129,7 @@ export function bootstrapModule(injector:Injector) { NgOptionHighlightModule, DynamicBootstrapModule, + OpenprojectPrincipalRenderingModule, ], exports: [ // Re-export all commonly used @@ -142,6 +143,7 @@ export function bootstrapModule(injector:Injector) { NgSelectModule, NgOptionHighlightModule, DynamicBootstrapModule, + OpenprojectPrincipalRenderingModule, OpDatePickerComponent, OpDateTimeComponent, @@ -187,7 +189,6 @@ export function bootstrapModule(injector:Injector) { // filter SlideToggleComponent, - OpPrincipalComponent, OpFormFieldComponent, OpFormBindingDirective, @@ -237,7 +238,6 @@ export function bootstrapModule(injector:Injector) { // User Avatar UserAvatarComponent, - OpPrincipalComponent, PersistentToggleComponent, HideSectionLinkComponent, diff --git a/frontend/src/app/modules/common/option-list/option-list.component.html b/frontend/src/app/modules/common/option-list/option-list.component.html index 5c286eaf1ae..d55552d456d 100644 --- a/frontend/src/app/modules/common/option-list/option-list.component.html +++ b/frontend/src/app/modules/common/option-list/option-list.component.html @@ -1,6 +1,6 @@ diff --git a/frontend/src/app/modules/common/option-list/option-list.component.sass b/frontend/src/app/modules/common/option-list/option-list.component.sass deleted file mode 100644 index c8a11529517..00000000000 --- a/frontend/src/app/modules/common/option-list/option-list.component.sass +++ /dev/null @@ -1,36 +0,0 @@ -.op-option-list - display: flex - flex-direction: column - font-size: 1rem - - // TODO: remove the [type] selector once globally overwriting styles are removed - &--input[type] - margin: 0 - margin-right: 0.5rem - - &--item - padding: 1rem 1rem 0.5rem 0.75rem - display: flex - border: 1px solid #cbd5e0 - background: #f7fafc - border-radius: 4px - - &:not(:last-child) - margin-bottom: 0.5rem - - &_selected - border: 1px solid #90cdf4 - background: #ebf8ff - - &-title, - &-description - margin: 0 - margin-bottom: 0.5rem - line-height: 1.2 - - &-title - font-weight: bold - font-size: 14px - - &-description - font-size: 12px diff --git a/frontend/src/app/modules/common/option-list/option-list.component.ts b/frontend/src/app/modules/common/option-list/option-list.component.ts index 639c0732bc8..2cb341c3d7a 100644 --- a/frontend/src/app/modules/common/option-list/option-list.component.ts +++ b/frontend/src/app/modules/common/option-list/option-list.component.ts @@ -14,6 +14,7 @@ import { export interface IOpOptionListOption { value:T; title:string; + disabled?:boolean; description?:string; } @@ -45,6 +46,14 @@ export class OpOptionListComponent implements ControlValueAccessor { this.onChange(value); } + getClassListForItem(option:IOpOptionListOption) { + return { + 'op-option-list--item': true, + 'op-option-list--item_selected': this.selected === option.value, + 'op-option-list--item_disabled': !!option.disabled, + }; + } + onChange = (_:IOpOptionListValue) => {}; onTouched = (_:IOpOptionListValue) => {}; diff --git a/frontend/src/app/modules/common/option-list/option-list.sass b/frontend/src/app/modules/common/option-list/option-list.sass index c8a11529517..b0bc7fe593e 100644 --- a/frontend/src/app/modules/common/option-list/option-list.sass +++ b/frontend/src/app/modules/common/option-list/option-list.sass @@ -22,6 +22,9 @@ border: 1px solid #90cdf4 background: #ebf8ff + &_disabled + color: #959595 + &-title, &-description margin: 0 diff --git a/frontend/src/app/modules/grids/widgets/members/members.component.ts b/frontend/src/app/modules/grids/widgets/members/members.component.ts index 27a15518129..631fa681969 100644 --- a/frontend/src/app/modules/grids/widgets/members/members.component.ts +++ b/frontend/src/app/modules/grids/widgets/members/members.component.ts @@ -8,6 +8,7 @@ import {MembershipResource} from "core-app/modules/hal/resources/membership-reso import {RoleResource} from "core-app/modules/hal/resources/role-resource"; import {APIV3Service} from "core-app/modules/apiv3/api-v3.service"; import {Apiv3ListParameters} from "core-app/modules/apiv3/paths/apiv3-list-resource.interface"; +import {HalResource} from "core-app/modules/hal/resources/hal-resource"; const DISPLAYED_MEMBERS_LIMIT = 100; @@ -24,7 +25,7 @@ export class WidgetMembersComponent extends AbstractWidgetComponent implements O }; public totalMembers:number; - public entriesByRoles:{[roleId:string]:{role:RoleResource, users:UserResource[]}} = {}; + public entriesByRoles:{[roleId:string]:{role:RoleResource, users:HalResource[]}} = {}; private entriesLoaded = false; public membersAddable:boolean = false; diff --git a/frontend/src/app/modules/hal/resources/membership-resource.ts b/frontend/src/app/modules/hal/resources/membership-resource.ts index 32e02ea2812..05a3b256735 100644 --- a/frontend/src/app/modules/hal/resources/membership-resource.ts +++ b/frontend/src/app/modules/hal/resources/membership-resource.ts @@ -38,7 +38,7 @@ export interface MembershipResourceLinks { } export interface MembershipResourceEmbedded { - principal:UserResource; + principal:HalResource; roles:RoleResource[]; project:ProjectResource; } diff --git a/frontend/src/app/modules/invite-user-modal/invite-user-modal.types.ts b/frontend/src/app/modules/invite-user-modal/invite-user-modal.types.ts new file mode 100644 index 00000000000..58da497a6d7 --- /dev/null +++ b/frontend/src/app/modules/invite-user-modal/invite-user-modal.types.ts @@ -0,0 +1,3 @@ +import {HalResource} from "core-app/modules/hal/resources/hal-resource"; + +export type PrincipalLike = HalResource|{ name: string}; \ No newline at end of file diff --git a/frontend/src/app/modules/invite-user-modal/invite-user.component.html b/frontend/src/app/modules/invite-user-modal/invite-user.component.html index dd8a6a58cda..b8baa0823ab 100644 --- a/frontend/src/app/modules/invite-user-modal/invite-user.component.html +++ b/frontend/src/app/modules/invite-user-modal/invite-user.component.html @@ -55,7 +55,10 @@ > diff --git a/frontend/src/app/modules/invite-user-modal/invite-user.component.ts b/frontend/src/app/modules/invite-user-modal/invite-user.component.ts index d5fac722cf7..fa7f05cad10 100644 --- a/frontend/src/app/modules/invite-user-modal/invite-user.component.ts +++ b/frontend/src/app/modules/invite-user-modal/invite-user.component.ts @@ -12,6 +12,8 @@ import {OpModalComponent} from 'core-app/modules/modal/modal.component'; import {OpModalLocalsToken} from "core-app/modules/modal/modal.service"; import {APIV3Service} from "core-app/modules/apiv3/api-v3.service"; import {RoleResource} from "core-app/modules/hal/resources/role-resource"; +import {HalResource} from "core-app/modules/hal/resources/hal-resource"; +import {ProjectResource} from "core-app/modules/hal/resources/project-resource"; enum Steps { ProjectSelection, @@ -23,9 +25,9 @@ enum Steps { } export enum PrincipalType { - User = 'user', - Placeholder = 'placeholder', - Group = 'group', + User = 'User', + Placeholder = 'PlaceholderUser', + Group = 'Group', } @Component({ @@ -48,8 +50,8 @@ export class InviteUserModalComponent extends OpModalComponent implements OnInit public data:any = null; public type:PrincipalType|null = null; - public project:any = null; - public principal = null; + public project:ProjectResource|null = null; + public principal:HalResource|null = null; public role:RoleResource|null = null; public message = ''; @@ -90,7 +92,7 @@ export class InviteUserModalComponent extends OpModalComponent implements OnInit onRoleSave(role:RoleResource) { this.role = role; - if (this.type === 'placeholder') { + if (this.type === PrincipalType.Placeholder) { this.goTo(Steps.Summary); } else { this.goTo(Steps.Message); @@ -102,6 +104,11 @@ export class InviteUserModalComponent extends OpModalComponent implements OnInit this.goTo(Steps.Summary); } + onSuccessfulSubmission($event:{ principal:HalResource }) { + this.principal = $event.principal; + this.goTo(Steps.Success); + } + goTo(step:Steps) { this.step = step; } diff --git a/frontend/src/app/modules/invite-user-modal/message/message.component.ts b/frontend/src/app/modules/invite-user-modal/message/message.component.ts index bd735c6b16e..b1be5c553df 100644 --- a/frontend/src/app/modules/invite-user-modal/message/message.component.ts +++ b/frontend/src/app/modules/invite-user-modal/message/message.component.ts @@ -11,6 +11,8 @@ import { FormGroup, } from '@angular/forms'; import {I18nService} from "core-app/modules/common/i18n/i18n.service"; +import {HalResource} from 'core-app/modules/hal/resources/hal-resource'; +import {ProjectResource} from 'core-app/modules/hal/resources/project-resource'; import {PrincipalType} from '../invite-user.component'; @Component({ @@ -20,8 +22,8 @@ import {PrincipalType} from '../invite-user.component'; }) export class MessageComponent implements OnInit { @Input() type:PrincipalType; - @Input() project:any = null; - @Input() principal:any = null; + @Input() project:ProjectResource; + @Input() principal:HalResource; @Input() message:string = ''; @Output() close = new EventEmitter(); @@ -59,7 +61,7 @@ export class MessageComponent implements OnInit { onSubmit($e:Event) { $e.preventDefault(); if (this.messageForm.invalid) { - this.messageForm.markAllAsTouched(); + this.messageForm.markAsDirty(); return; } diff --git a/frontend/src/app/modules/invite-user-modal/principal/principal-search.component.html b/frontend/src/app/modules/invite-user-modal/principal/principal-search.component.html index d548ac06064..f7d05c3fded 100644 --- a/frontend/src/app/modules/invite-user-modal/principal/principal-search.component.html +++ b/frontend/src/app/modules/invite-user-modal/principal/principal-search.component.html @@ -1,5 +1,6 @@ + + {{item.value.name }} + + -
{{ item.name }}
- -
{{ text.alreadyAMember() }}
+ > + +
{{ item.value.name }}
+ + +
{{ text.alreadyAMember() }}
+
- +
{{ text.noResults[type] }}
- +
- + + {{ text.inviteNewUser }} + {{ input }}
- + + {{ text.createNewPlaceholder }} + {{ input }}
diff --git a/frontend/src/app/modules/invite-user-modal/principal/principal-search.component.ts b/frontend/src/app/modules/invite-user-modal/principal/principal-search.component.ts index 1471e97e459..f816e78f50c 100644 --- a/frontend/src/app/modules/invite-user-modal/principal/principal-search.component.ts +++ b/frontend/src/app/modules/invite-user-modal/principal/principal-search.component.ts @@ -8,11 +8,13 @@ import { } from '@angular/core'; import {FormControl} from "@angular/forms"; import {Observable, BehaviorSubject, combineLatest, forkJoin} from "rxjs"; -import {debounceTime, distinctUntilChanged, first, shareReplay, map, switchMap} from "rxjs/operators"; +import {debounceTime, distinctUntilChanged, tap, shareReplay, map, switchMap} from "rxjs/operators"; import {APIV3Service} from "core-app/modules/apiv3/api-v3.service"; import {ApiV3FilterBuilder} from "core-components/api/api-v3/api-v3-filter-builder"; import {I18nService} from "core-app/modules/common/i18n/i18n.service"; import {UntilDestroyedMixin} from "core-app/helpers/angular/until-destroyed.mixin"; +import {ProjectResource} from "core-app/modules/hal/resources/project-resource"; +import {PrincipalLike} from "core-app/modules/invite-user-modal/invite-user-modal.types"; import {PrincipalType} from '../invite-user.component'; @Component({ @@ -22,30 +24,27 @@ import {PrincipalType} from '../invite-user.component'; export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnInit { @Input('opFormBinding') principalControl:FormControl; @Input() type:PrincipalType; - @Input() project:any = null; + @Input() project:ProjectResource; - @Output() createNew = new EventEmitter(); + @Output() createNew = new EventEmitter(); public input$ = new BehaviorSubject(''); public input = ''; public items$:Observable; public canInviteByEmail$:Observable; public canCreateNewPlaceholder$:Observable; + public showAddTag = false; public text = { alreadyAMember: () => this.I18n.t('js.invite_user_modal.principal.already_member_message', { project: this.project?.name, }), - inviteNewUser: () => this.I18n.t('js.invite_user_modal.principal.invite_user', { - email: this.input, - }), - createNewPlaceholder: () => this.I18n.t('js.invite_user_modal.principal.create_new_placeholder', { - name: this.input, - }), + inviteNewUser: this.I18n.t('js.invite_user_modal.principal.invite_user'), + createNewPlaceholder: this.I18n.t('js.invite_user_modal.principal.create_new_placeholder'), noResults: { - user: this.I18n.t('js.invite_user_modal.principal.no_results_user'), - placeholder: this.I18n.t('js.invite_user_modal.principal.no_results_placeholder'), - group: this.I18n.t('js.invite_user_modal.principal.no_results_group'), + User: this.I18n.t('js.invite_user_modal.principal.no_results_user'), + PlaceholderUser: this.I18n.t('js.invite_user_modal.principal.no_results_placeholder'), + Group: this.I18n.t('js.invite_user_modal.principal.no_results_group'), }, }; @@ -72,7 +71,7 @@ export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnI this.items$, this.input$, ).pipe( - map(([elements, input]) => this.type === 'user' && input?.includes('@') && !elements.find((el:any) => el.email === input)), + map(([elements, input]) => this.type === PrincipalType.User && input?.includes('@') && !elements.find((el:any) => el.email === input)), ); this.canCreateNewPlaceholder$ = combineLatest( @@ -87,6 +86,13 @@ export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnI return !!input && !elements.find((el:any) => el.name === input); }), ); + + combineLatest( + this.canInviteByEmail$, + this.canCreateNewPlaceholder$, + ).pipe( + map(([canInviteByEmail, canCreateNewPlaceholder]:boolean[]) => canInviteByEmail || canCreateNewPlaceholder) + ).subscribe(showAddTag => { this.showAddTag = showAddTag; }); } ngOnInit() { @@ -95,37 +101,27 @@ export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnI } createNewFromInput() { - this.input$ - .pipe(first()) - .subscribe((input:string) => { - this.createNew.emit(input); - }); + this.createNew.emit({ name: this.input }); } private loadPrincipalData(searchTerm:string) { - const type = { - placeholder: 'PlaceholderUser', - user: 'User', - group: 'Group', - }[this.type]; - const nonMemberFilter = new ApiV3FilterBuilder(); if (searchTerm) { nonMemberFilter.add('name', '~', [searchTerm]); } nonMemberFilter.add('status', '!', [3]); - nonMemberFilter.add('type', '=', [type]); + nonMemberFilter.add('type', '=', [this.type]); nonMemberFilter.add('member', '!', [this.project?.id]); + const nonMembers = this.apiV3Service.principals.filtered(nonMemberFilter).get(); const memberFilter = new ApiV3FilterBuilder(); if (searchTerm) { memberFilter.add('name', '~', [searchTerm]); } memberFilter.add('status', '!', [3]); - memberFilter.add('type', '=', [type]); - nonMemberFilter.add('member', '=', [this.project?.id]); + memberFilter.add('type', '=', [this.type]); + memberFilter.add('member', '=', [this.project?.id]); const members = this.apiV3Service.principals.filtered(memberFilter).get(); - const nonMembers = this.apiV3Service.principals.filtered(nonMemberFilter).get(); return forkJoin({ members, @@ -133,12 +129,12 @@ export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnI }) .pipe( map(({ members, nonMembers }) => [ - ...members.elements.map((member:any) => ({ - ...member, + ...nonMembers.elements.map((nonMember:any) => ({ + value: nonMember, disabled: false, })), - ...nonMembers.elements.map((nonMember:any) => ({ - ...nonMember, + ...members.elements.map((member:any) => ({ + value: member, disabled: true, })), ]), diff --git a/frontend/src/app/modules/invite-user-modal/principal/principal.component.html b/frontend/src/app/modules/invite-user-modal/principal/principal.component.html index 56bfaab8fce..921de1d360e 100644 --- a/frontend/src/app/modules/invite-user-modal/principal/principal.component.html +++ b/frontend/src/app/modules/invite-user-modal/principal/principal.component.html @@ -10,7 +10,7 @@ required >
- {{ text.inviteUser() }} + {{ text.inviteUser }} {{ principal.name }}
-
- {{ text.createNew.group() }} - -
-
(); - @Output() save = new EventEmitter<{ principal:any, isAlreadyMember:boolean }>(); + @Output() save = new EventEmitter<{ principal:PrincipalLike, isAlreadyMember:boolean }>(); @Output() back = new EventEmitter(); + public PrincipalType = PrincipalType; + public text = { title: () => this.I18n.t('js.invite_user_modal.title.invite_to_project', { type: this.I18n.t(`js.invite_user_modal.title.${this.type}`), project: this.project.name, }), label: { - user: this.I18n.t('js.invite_user_modal.principal.label.name_or_email'), - placeholder: this.I18n.t('js.invite_user_modal.principal.label.name'), - group: this.I18n.t('js.invite_user_modal.principal.label.name'), + User: this.I18n.t('js.invite_user_modal.principal.label.name_or_email'), + PlaceholderUser: this.I18n.t('js.invite_user_modal.principal.label.name'), + Group: this.I18n.t('js.invite_user_modal.principal.label.name'), }, - inviteUser: () => this.I18n.t('js.invite_user_modal.principal.invite_user', { - email: this.principalControl?.value?.name, - }), changeUserSelection: this.I18n.t('js.invite_user_modal.principal.change_user_selection'), changePlaceholderSelection: this.I18n.t('js.invite_user_modal.principal.change_placeholder_selection'), changeGroupSelection: this.I18n.t('js.invite_user_modal.principal.change_group_selection'), - createNew: { - placeholder: () => this.I18n.t('js.invite_user_modal.principal.create_new_placeholder', { - name: this.principalControl?.value?.name - }), - group: () => this.I18n.t('js.invite_user_modal.principal.create_new_group', { - name: this.principalControl?.value?.name - }), - }, + inviteUser: this.I18n.t('js.invite_user_modal.principal.invite_user'), + createNewPlaceholder: this.I18n.t('js.invite_user_modal.principal.create_new_placeholder'), required: { - user: this.I18n.t('js.invite_user_modal.principal.required.user'), - placeholder: this.I18n.t('js.invite_user_modal.principal.required.placeholder'), - group: this.I18n.t('js.invite_user_modal.principal.required.group'), + User: this.I18n.t('js.invite_user_modal.principal.required.user'), + PlaceholderUser: this.I18n.t('js.invite_user_modal.principal.required.placeholder'), + Group: this.I18n.t('js.invite_user_modal.principal.required.group'), }, backButton: this.I18n.t('js.invite_user_modal.back'), nextButton: this.I18n.t('js.invite_user_modal.principal.next_button'), @@ -68,8 +64,16 @@ export class PrincipalComponent implements OnInit { return this.principalForm.get('principal'); } + get principal():PrincipalLike|undefined { + return this.principalControl?.value; + } + + get hasPrincipalSelected() { + return !!this.principal; + } + get isNewPrincipal() { - return typeof this.principalControl?.value === 'string'; + return this.hasPrincipalSelected && !(this.principal instanceof HalResource); } get isMemberOfCurrentProject() { @@ -79,10 +83,10 @@ export class PrincipalComponent implements OnInit { constructor(readonly I18n:I18nService) {} ngOnInit() { - this.principalControl?.setValue(this.principal); + this.principalControl?.setValue(this.storedPrincipal); } - createNewFromInput(input:string) { + createNewFromInput(input:PrincipalLike) { this.principalControl?.setValue(input); } @@ -90,12 +94,12 @@ export class PrincipalComponent implements OnInit { $e.preventDefault(); if (this.principalForm.invalid) { - this.principalForm.markAllAsTouched(); + this.principalForm.markAsDirty(); return; } this.save.emit({ - principal: this.principalControl?.value, + principal: this.principal!, isAlreadyMember: this.isMemberOfCurrentProject, }); } diff --git a/frontend/src/app/modules/invite-user-modal/project-selection/project-selection.component.html b/frontend/src/app/modules/invite-user-modal/project-selection/project-selection.component.html index 6a106ea4ab6..9e9c6a733be 100644 --- a/frontend/src/app/modules/invite-user-modal/project-selection/project-selection.component.html +++ b/frontend/src/app/modules/invite-user-modal/project-selection/project-selection.component.html @@ -30,7 +30,7 @@ >
diff --git a/frontend/src/app/modules/invite-user-modal/project-selection/project-selection.component.ts b/frontend/src/app/modules/invite-user-modal/project-selection/project-selection.component.ts index 278e4d7224b..8b3b0675438 100644 --- a/frontend/src/app/modules/invite-user-modal/project-selection/project-selection.component.ts +++ b/frontend/src/app/modules/invite-user-modal/project-selection/project-selection.component.ts @@ -12,6 +12,8 @@ import { Validators, } from '@angular/forms'; import {I18nService} from "core-app/modules/common/i18n/i18n.service"; +import {BannersService} from "core-app/modules/common/enterprise/banners.service"; +import {IOpOptionListOption} from "core-app/modules/common/option-list/option-list.component"; import {PrincipalType} from '../invite-user.component'; @Component({ @@ -37,21 +39,16 @@ export class ProjectSelectionComponent implements OnInit { nextButton: this.I18n.t('js.invite_user_modal.project.next_button'), }; - public typeOptions = [ + public typeOptions:IOpOptionListOption[] = [ { - value: 'user', - title: 'User', - description: 'Permissions based on the assigned role in the selected project' + value: PrincipalType.User, + title: this.I18n.t('js.invite_user_modal.type.user.title'), + description: this.I18n.t('js.invite_user_modal.type.user.description'), }, { - value: 'group', - title: 'Group', - description: 'Permissions based on the assigned role in the selected project' - }, - { - value: 'placeholder', - title: 'Placeholder', - description: 'Has no access to the proejct and no emails are sent out' + value: PrincipalType.Group, + title: this.I18n.t('js.invite_user_modal.type.group.title'), + description: this.I18n.t('js.invite_user_modal.type.group.description'), }, ]; @@ -66,17 +63,34 @@ export class ProjectSelectionComponent implements OnInit { constructor( readonly I18n:I18nService, readonly elementRef:ElementRef, + readonly bannersService:BannersService, ) {} ngOnInit() { this.typeControl?.setValue(this.type); this.projectControl?.setValue(this.project); + + this.typeOptions.push({ + value: PrincipalType.Placeholder, + title: this.bannersService.eeShowBanners + ? this.I18n.t('js.invite_user_modal.type.placeholder.title_no_ee') + : this.I18n.t('js.invite_user_modal.type.placeholder.title'), + description: this.bannersService.eeShowBanners + ? this.I18n.t('js.invite_user_modal.type.placeholder.description_no_ee', { + eeHref: this.bannersService.getEnterPriseEditionUrl({ + referrer: 'placeholder-users', + hash: 'placeholder-users', + }), + }) + : this.I18n.t('js.invite_user_modal.type.placeholder.description'), + disabled: this.bannersService.eeShowBanners, + }); } onSubmit($e:Event) { $e.preventDefault(); if (this.projectAndTypeForm.invalid) { - this.projectAndTypeForm.markAllAsTouched(); + this.projectAndTypeForm.markAsDirty(); return; } diff --git a/frontend/src/app/modules/invite-user-modal/role/role-search.component.ts b/frontend/src/app/modules/invite-user-modal/role/role-search.component.ts index cd63de3f8a7..b8d90aacd9d 100644 --- a/frontend/src/app/modules/invite-user-modal/role/role-search.component.ts +++ b/frontend/src/app/modules/invite-user-modal/role/role-search.component.ts @@ -24,7 +24,7 @@ export class RoleSearchComponent extends UntilDestroyedMixin implements OnInit { public items$:Observable; public text = { - noRolesFound: this.I18n.t('js.invite_user_modal.role_search.no_roles_found'), + noRolesFound: this.I18n.t('js.invite_user_modal.role.no_roles_found'), }; constructor( @@ -39,12 +39,12 @@ export class RoleSearchComponent extends UntilDestroyedMixin implements OnInit { .pipe( this.untilDestroyed(), debounceTime(200), + filter(input => typeof input === 'string'), map((input:string) => input.toLowerCase()), distinctUntilChanged(), ), this.roles$, ).pipe( - tap(console.log), map(([input, roles]:[string, any[]]) => roles.filter((role) => !input || role.name.toLowerCase().indexOf(input) !== -1)) ); } diff --git a/frontend/src/app/modules/invite-user-modal/role/role.component.ts b/frontend/src/app/modules/invite-user-modal/role/role.component.ts index 32026183777..3f66d3d96e9 100644 --- a/frontend/src/app/modules/invite-user-modal/role/role.component.ts +++ b/frontend/src/app/modules/invite-user-modal/role/role.component.ts @@ -13,6 +13,8 @@ import { import {I18nService} from "core-app/modules/common/i18n/i18n.service"; import {PrincipalType} from '../invite-user.component'; import {RoleResource} from "core-app/modules/hal/resources/role-resource"; +import {ProjectResource} from "core-app/modules/hal/resources/project-resource"; +import {HalResource} from 'core-app/modules/hal/resources/hal-resource'; @Component({ selector: 'op-ium-role', @@ -21,9 +23,9 @@ import {RoleResource} from "core-app/modules/hal/resources/role-resource"; }) export class RoleComponent implements OnInit { @Input() type:PrincipalType; - @Input() project:any = null; - @Input() principal:any = null; - @Input() role:any = null; + @Input() project:ProjectResource; + @Input() principal:HalResource; + @Input() role:RoleResource; @Output() close = new EventEmitter(); @Output() back = new EventEmitter(); @@ -60,7 +62,7 @@ export class RoleComponent implements OnInit { onSubmit($e:Event) { $e.preventDefault(); if (this.roleForm.invalid) { - this.roleForm.markAllAsTouched(); + this.roleForm.markAsDirty(); return; } diff --git a/frontend/src/app/modules/invite-user-modal/success/success.component.html b/frontend/src/app/modules/invite-user-modal/success/success.component.html index 1157ecddc01..1cf5c838b4b 100644 --- a/frontend/src/app/modules/invite-user-modal/success/success.component.html +++ b/frontend/src/app/modules/invite-user-modal/success/success.component.html @@ -1,18 +1,16 @@
- -
- +

-
-
diff --git a/frontend/src/app/modules/invite-user-modal/success/success.component.sass b/frontend/src/app/modules/invite-user-modal/success/success.component.sass index e69de29bb2d..80036ed5202 100644 --- a/frontend/src/app/modules/invite-user-modal/success/success.component.sass +++ b/frontend/src/app/modules/invite-user-modal/success/success.component.sass @@ -0,0 +1,9 @@ +.op-ium-success + flex-grow: 1 + display: flex + flex-direction: column + + .op-modal--body + justify-content: center + align-items: center + text-align: center diff --git a/frontend/src/app/modules/invite-user-modal/success/success.component.ts b/frontend/src/app/modules/invite-user-modal/success/success.component.ts index 332313ab462..9cf0d7ddae8 100644 --- a/frontend/src/app/modules/invite-user-modal/success/success.component.ts +++ b/frontend/src/app/modules/invite-user-modal/success/success.component.ts @@ -7,6 +7,9 @@ import { } from '@angular/core'; import {I18nService} from "core-app/modules/common/i18n/i18n.service"; import {PrincipalType} from '../invite-user.component'; +import {HalResource} from "core-app/modules/hal/resources/hal-resource"; +import {ProjectResource} from "core-app/modules/hal/resources/project-resource"; +import {ImageHelpers} from "core-app/helpers/images/path-helper"; @Component({ selector: 'op-ium-success', @@ -14,20 +17,25 @@ import {PrincipalType} from '../invite-user.component'; styleUrls: ['./success.component.sass'], }) export class SuccessComponent { - @Input() principal:any = null; - @Input() project:any = null; + @Input() principal:HalResource; + @Input() project:ProjectResource; @Input() type:PrincipalType; @Output() close = new EventEmitter(); + public PrincipalType = PrincipalType; + + user_image = ImageHelpers.imagePath('invite-user-modal/successful-invite.svg'); + placeholder_image = ImageHelpers.imagePath('invite-user-modal/placeholder-added.svg'); + public text = { title: () => this.I18n.t('js.invite_user_modal.success.title', { principal: this.principal.name, }), description: { - user: () => this.I18n.t('js.invite_user_modal.success.description.user', { project: this.project?.name }), - placeholder: () => this.I18n.t('js.invite_user_modal.success.description.placeholder', { project: this.project?.name }), - group: () => this.I18n.t('js.invite_user_modal.success.description.group', { project: this.project?.name }), + User: () => this.I18n.t('js.invite_user_modal.success.description.user', { project: this.project?.name }), + PlaceholderUser: () => this.I18n.t('js.invite_user_modal.success.description.placeholder', { project: this.project?.name }), + Group: () => this.I18n.t('js.invite_user_modal.success.description.group', { project: this.project?.name }), }, nextButton: this.I18n.t('js.invite_user_modal.success.next_button'), }; diff --git a/frontend/src/app/modules/invite-user-modal/summary/summary.component.html b/frontend/src/app/modules/invite-user-modal/summary/summary.component.html index 3318b364b6d..6657b1e3601 100644 --- a/frontend/src/app/modules/invite-user-modal/summary/summary.component.html +++ b/frontend/src/app/modules/invite-user-modal/summary/summary.component.html @@ -19,7 +19,7 @@

{{ message }}

@@ -28,8 +28,11 @@ diff --git a/frontend/src/app/modules/invite-user-modal/summary/summary.component.ts b/frontend/src/app/modules/invite-user-modal/summary/summary.component.ts index deebe413f17..254bba83014 100644 --- a/frontend/src/app/modules/invite-user-modal/summary/summary.component.ts +++ b/frontend/src/app/modules/invite-user-modal/summary/summary.component.ts @@ -5,10 +5,15 @@ import { Output, ElementRef, } from '@angular/core'; +import {Observable, of} from "rxjs"; +import {mapTo, switchMap} from "rxjs/operators"; import {I18nService} from "core-app/modules/common/i18n/i18n.service"; import {APIV3Service} from "core-app/modules/apiv3/api-v3.service"; -import {PrincipalType} from '../invite-user.component'; import {RoleResource} from "core-app/modules/hal/resources/role-resource"; +import {PrincipalLike} from "core-app/modules/invite-user-modal/invite-user-modal.types"; +import {HalResource} from "core-app/modules/hal/resources/hal-resource"; +import {ProjectResource} from 'core-app/modules/hal/resources/project-resource'; +import {PrincipalType} from '../invite-user.component'; @Component({ selector: 'op-ium-summary', @@ -17,15 +22,17 @@ import {RoleResource} from "core-app/modules/hal/resources/role-resource"; }) export class SummaryComponent { @Input() type:PrincipalType; - @Input() project:any = null; + @Input() project:ProjectResource; @Input() role:RoleResource; - @Input() principal:any = null; + @Input() principal:PrincipalLike; @Input() message:string = ''; @Output() close = new EventEmitter(); @Output() back = new EventEmitter(); @Output() save = new EventEmitter(); + public PrincipalType = PrincipalType; + public text = { title: () => this.I18n.t('js.invite_user_modal.title.invite_principal_to_project', { principal: this.principal?.name, @@ -33,9 +40,9 @@ export class SummaryComponent { }), projectLabel: this.I18n.t('js.invite_user_modal.project.label'), principalLabel: { - user: this.I18n.t('js.invite_user_modal.principal.label.name_or_email'), - placeholder: this.I18n.t('js.invite_user_modal.principal.label.name'), - group: this.I18n.t('js.invite_user_modal.principal.label.name'), + User: this.I18n.t('js.invite_user_modal.principal.label.name_or_email'), + PlaceholderUser: this.I18n.t('js.invite_user_modal.principal.label.name'), + Group: this.I18n.t('js.invite_user_modal.principal.label.name'), }, roleLabel: () => this.I18n.t('js.invite_user_modal.role.label', { project: this.project?.name, @@ -52,36 +59,52 @@ export class SummaryComponent { readonly I18n:I18nService, readonly elementRef:ElementRef, readonly api:APIV3Service, - ) {} - - async invite() { - const principal = await (async () => { - if (this.principal.id) { - return this.principal; - } - - switch (this.type) { - case PrincipalType.User: - return this.api.users.post({ - email: this.principal.name, - firstName: this.principal.email, - status: 'invited', - }); - case PrincipalType.Placeholder: - return this.api.placeholder_users.post({ name: this.principal.name }); - } - })(); - - return this.api.memberships.post({ - principal, - project: this.project, - roles: [this.role], - }); + ) { } - async onSubmit($e:Event) { + invite() { + return of(this.principal) + .pipe( + switchMap((principal:PrincipalLike) => this.createPrincipal(principal)), + switchMap((principal:HalResource) => + this.api.memberships + .post({ + principal, + project: this.project, + roles: [this.role], + }) + .pipe( + mapTo(principal) + ) + ) + ); + } + + private createPrincipal(principal:PrincipalLike):Observable { + if (principal instanceof HalResource) { + return of(principal); + } + + switch (this.type) { + case PrincipalType.User: + return this.api.users.post({ + email: principal.name, + status: 'invited', + }); + case PrincipalType.Placeholder: + return this.api.placeholder_users.post({ name: principal.name }); + default: + throw new Error("Unsupported PrincipalType given"); + } + } + + onSubmit($e:Event) { $e.preventDefault(); - this.save.emit({ principal: this.principal }); + this + .invite() + .subscribe((principal) => + this.save.emit({ principal }) + ); } } diff --git a/frontend/src/app/modules/modal/modal-overrides.sass b/frontend/src/app/modules/modal/modal-overrides.sass new file mode 100644 index 00000000000..d95312cbf97 --- /dev/null +++ b/frontend/src/app/modules/modal/modal-overrides.sass @@ -0,0 +1,7 @@ +// Some backend modals come wrapped once extra. Because of the way +// augmented modals are loaded, this is not so easy to change. +// TODO: When refactoring the modal service, this should be removed +.op-modal > .form:only-child + display: flex + flex-direction: column + max-height: 100vh diff --git a/frontend/src/app/modules/modal/modal.module.sass b/frontend/src/app/modules/modal/modal.module.sass index 99965932350..2267b5081a4 100644 --- a/frontend/src/app/modules/modal/modal.module.sass +++ b/frontend/src/app/modules/modal/modal.module.sass @@ -1,3 +1,4 @@ +@import './modal-overrides' @import './modal-overlay' @import './modal-delivery-element' @import './modal' diff --git a/frontend/src/app/modules/modal/modal.sass b/frontend/src/app/modules/modal/modal.sass index 6318e2b26e9..433f10af5f5 100644 --- a/frontend/src/app/modules/modal/modal.sass +++ b/frontend/src/app/modules/modal/modal.sass @@ -1,11 +1,13 @@ // TODO: Check out -danger-zone and modal-close-button patterns .op-modal + $modal-padding: 2rem + display: flex flex-direction: column align-items: stretch background: white - width: 30rem + width: 40rem min-height: 20vh max-width: 100vw @@ -16,7 +18,9 @@ min-height: 40vh &--body - padding: 1rem + display: flex + flex-direction: column + padding: $modal-padding flex-grow: 1 flex-shrink: 1 overflow-y: auto @@ -24,7 +28,7 @@ &--header display: flex - padding: 2rem 1rem 1rem 1rem + padding: 2rem $modal-padding 1rem $modal-padding position: relative border-bottom: 1px solid #eee @@ -51,11 +55,11 @@ margin-right: auto &--footer - padding: 1rem 1rem 2rem 1rem + padding: 1rem $modal-padding 2rem $modal-padding &--close-button height: 100% - width: 3rem + width: 5rem padding: 0.45rem 0 display: flex justify-content: center diff --git a/frontend/src/app/modules/common/principal/principal-helper.ts b/frontend/src/app/modules/principal/principal-helper.ts similarity index 100% rename from frontend/src/app/modules/common/principal/principal-helper.ts rename to frontend/src/app/modules/principal/principal-helper.ts diff --git a/frontend/src/app/modules/common/principal/principal-renderer.service.ts b/frontend/src/app/modules/principal/principal-renderer.service.ts similarity index 97% rename from frontend/src/app/modules/common/principal/principal-renderer.service.ts rename to frontend/src/app/modules/principal/principal-renderer.service.ts index 82fc6d814a2..9c5b0530948 100644 --- a/frontend/src/app/modules/common/principal/principal-renderer.service.ts +++ b/frontend/src/app/modules/principal/principal-renderer.service.ts @@ -2,7 +2,8 @@ import {Injectable} from "@angular/core"; import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service"; import {ColorsService} from "core-app/modules/common/colors/colors.service"; import {APIV3Service} from "core-app/modules/apiv3/api-v3.service"; -import {PrincipalHelper} from "core-app/modules/common/principal/principal-helper"; + +import {PrincipalHelper} from "./principal-helper"; import PrincipalType = PrincipalHelper.PrincipalType; export interface PrincipalLike { diff --git a/frontend/src/app/modules/principal/principal-rendering.module.ts b/frontend/src/app/modules/principal/principal-rendering.module.ts new file mode 100644 index 00000000000..d55ee7d27d7 --- /dev/null +++ b/frontend/src/app/modules/principal/principal-rendering.module.ts @@ -0,0 +1,17 @@ +import {NgModule} from "@angular/core"; +import {OpPrincipalComponent} from './principal.component'; +import {PrincipalRendererService} from "./principal-renderer.service"; + +@NgModule({ + imports: [], + exports: [ + OpPrincipalComponent, + ], + providers: [ + PrincipalRendererService, + ], + declarations: [ + OpPrincipalComponent, + ], +}) +export class OpenprojectPrincipalRenderingModule { } diff --git a/frontend/src/app/modules/common/principal/op-principal.component.ts b/frontend/src/app/modules/principal/principal.component.ts similarity index 94% rename from frontend/src/app/modules/common/principal/op-principal.component.ts rename to frontend/src/app/modules/principal/principal.component.ts index 34109f08c5d..a9526c255a2 100644 --- a/frontend/src/app/modules/common/principal/op-principal.component.ts +++ b/frontend/src/app/modules/principal/principal.component.ts @@ -31,9 +31,10 @@ import {I18nService} from 'core-app/modules/common/i18n/i18n.service'; import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.service'; import {TimezoneService} from 'core-components/datetime/timezone.service'; import {APIV3Service} from "core-app/modules/apiv3/api-v3.service"; -import {PrincipalHelper} from "core-app/modules/common/principal/principal-helper"; + +import {PrincipalHelper} from "./principal-helper"; import PrincipalPluralType = PrincipalHelper.PrincipalPluralType; -import {PrincipalLike, PrincipalRendererService} from "core-app/modules/common/principal/principal-renderer.service"; +import {PrincipalLike, PrincipalRendererService} from "./principal-renderer.service"; @Component({ template: '', diff --git a/frontend/src/assets/images/invite-user-modal/placeholder-added.svg b/frontend/src/assets/images/invite-user-modal/placeholder-added.svg new file mode 100644 index 00000000000..40cc9133603 --- /dev/null +++ b/frontend/src/assets/images/invite-user-modal/placeholder-added.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/assets/images/invite-user-modal/successful-invite.svg b/frontend/src/assets/images/invite-user-modal/successful-invite.svg new file mode 100644 index 00000000000..1943284d106 --- /dev/null +++ b/frontend/src/assets/images/invite-user-modal/successful-invite.svg @@ -0,0 +1,4 @@ + + + + diff --git a/spec/features/users/invite_user_modal_spec.rb b/spec/features/users/invite_user_modal_spec.rb index b7fc93eb71f..54e992dee6c 100644 --- a/spec/features/users/invite_user_modal_spec.rb +++ b/spec/features/users/invite_user_modal_spec.rb @@ -28,6 +28,7 @@ require 'spec_helper' + feature 'Invite user modal', type: :feature, js: true do shared_let(:project) { FactoryBot.create :project } shared_let(:work_package) { FactoryBot.create :work_package, project: project } @@ -39,22 +40,132 @@ feature 'Invite user modal', type: :feature, js: true do member_through_role: role end - let!(:non_project_user) do - FactoryBot.create :user, - firstname: 'Nonproject', - lastname: 'User' - end - let!(:role) do FactoryBot.create :role, name: 'Member', permissions: permissions end - describe 'through the assignee field' do + let(:modal) do + ::Components::Users::InviteUserModal.new project: project, + principal: principal, + role: role + end + + shared_examples 'invites the principal to the project' do + it 'will invite that principal to the project' do + modal.run_all_steps + + assignee_field.expect_active! + # TODO assignee field should contain the user name now + #assignee_field.expect_value principal.name + assignee_field.expect_value nil + + new_member = project.reload.member_principals.find_by(user_id: added_principal.id) + expect(new_member).to be_present + expect(new_member.roles).to eq [role] + end + end + + describe 'inviting a non-project existing user' do + describe 'through the assignee field' do + let(:wp_page) { Pages::FullWorkPackage.new(work_package, project) } + let(:assignee_field) { wp_page.edit_field :assignee } + + before do + wp_page.visit! + + assignee_field.activate! + + find('.ng-dropdown-footer button', text: 'Invite', wait: 10).click + end + + context 'with a non project user' do + let!(:principal) do + FactoryBot.create :user, + firstname: 'Nonproject', + lastname: 'User' + end + it 'can add an existing user to the project' do + modal.run_all_steps + + # TODO assignee field should close and contain the user name now + #assignee_field.expect_value principal.name + assignee_field.expect_active! + assignee_field.expect_value nil + + # But the user got created + new_member = project.reload.members.find_by(user_id: principal.id) + expect(new_member).to be_present + expect(new_member.roles).to eq [role] + end + end + + context 'with a user to be invited' do + let(:principal) { FactoryBot.build :invited_user } + + context 'when the current user has permissions to create a user' do + let(:permissions) { %i[view_work_packages edit_work_packages manage_members manage_user] } + + it_behaves_like 'invites the principal to the project' do + let(:added_principal) { User.find_by!(mail: principal.mail) } + end + end + end + + describe 'inviting placeholders' do + let(:principal) { FactoryBot.build :placeholder_user, name: 'MY NEW PLACEHOLDER' } + + context 'system has enterprise', with_ee: %i[placeholder_users] do + describe 'create a new placeholder' do + context 'with permissions to manage placeholders' do + let(:permissions) { %i[view_work_packages edit_work_packages manage_members manage_placeholder_user] } + + it_behaves_like 'invites the principal to the project' do + let(:added_principal) { PlaceholderUser.find_by!(name: 'MY NEW PLACEHOLDER') } + end + end + + context 'without permissions to manage placeholders' do + let(:permissions) { %i[view_work_packages edit_work_packages manage_members manage_placeholder_user] } + it 'does not allow to invite a new placeholder' do + skip "TODO wait for permissions API" + end + end + end + + context 'with an existing placeholder' do + let(:principal) { FactoryBot.create :placeholder_user } + + it_behaves_like 'invites the principal to the project' do + let(:added_principal) { principal } + end + end + end + + context 'system has no enterprise' do + it 'shows the modal with placeholder option disabled' do + modal.within_modal do + expect(page).to have_field 'Placeholder user', disabled: true + end + end + end + end + + describe 'inviting groups' do + let(:principal) { FactoryBot.create :group, name: 'MY NEW GROUP' } + + it_behaves_like 'invites the principal to the project' do + let(:added_principal) { principal } + end + end + end + end + + context 'when the user has no permission to manage members' do + let(:permissions) { %i[view_work_packages edit_work_packages] } let(:wp_page) { Pages::FullWorkPackage.new(work_package, project) } let(:assignee_field) { wp_page.edit_field :assignee } - let(:modal) { ::Components::Users::InviteUserModal.new } before do wp_page.visit! @@ -63,39 +174,7 @@ feature 'Invite user modal', type: :feature, js: true do it 'can add an existing user to the project' do assignee_field.activate! - find('.ng-dropdown-footer button', text: 'Invite', wait: 10).click - - modal.expect_open - - # STEP 1: Project and type - modal.expect_title 'Invite user' - modal.autocomplete project.name - modal.select_type 'User' - - modal.next - - # STEP 2: User name - modal.autocomplete non_project_user.name - modal.next - - # STEP 3: Role name - modal.autocomplete role.name - modal.next - - # STEP 4: Invite message - modal.invitation_message 'Welcome user!' - modal.click_modal_button 'Review Invitation' - - modal.within_modal do - expect(page).to have_text project.name - expect(page).to have_text non_project_user.name - expect(page).to have_text role.name - expect(page).to have_text 'Welcome user!' - end - end - - context 'when the user has no permission to manage members' do - let(:permissions) { %i[view_work_packages edit_work_packages] } + expect(page).to have_no_selector('.ng-dropdown-footer', text: 'Invite') end end end diff --git a/spec/services/users/create_user_service_spec.rb b/spec/services/users/create_user_service_spec.rb index 1d3d137ad7f..5ed4dae63cb 100644 --- a/spec/services/users/create_user_service_spec.rb +++ b/spec/services/users/create_user_service_spec.rb @@ -43,6 +43,14 @@ describe Users::CreateService do end end + context 'and the user has no names set' do + let(:model_instance) { FactoryBot.build :invited_user, firstname: nil, lastname: nil, mail: 'foo@example.com' } + it 'will call UserInvitation' do + expect(::UserInvitation).to receive(:invite_user!).with(model_instance).and_return(model_instance) + expect(subject).to be_success + end + end + context 'and the mail is empty' do let(:model_instance) { FactoryBot.build :invited_user, mail: nil } it 'will call not call UserInvitation' do diff --git a/spec/support/components/common/modal.rb b/spec/support/components/common/modal.rb index c51a969ca46..5c62df4e6c4 100644 --- a/spec/support/components/common/modal.rb +++ b/spec/support/components/common/modal.rb @@ -48,6 +48,12 @@ module Components expect(page).to have_no_selector(selector) end + def expect_text(text) + within_modal do + expect(page).to have_text(text) + end + end + def click_modal_button(text) within_modal do click_button text diff --git a/spec/support/components/users/invite_user_modal.rb b/spec/support/components/users/invite_user_modal.rb index a5bcf97cff0..074e003a35d 100644 --- a/spec/support/components/users/invite_user_modal.rb +++ b/spec/support/components/users/invite_user_modal.rb @@ -33,9 +33,105 @@ module Components class InviteUserModal < ::Components::Common::Modal include ::Components::NgSelectAutocompleteHelpers - def autocomplete(query) + attr_accessor :project, :principal, :role, :invite_message + + def initialize(project:, principal:, role:, invite_message: 'Welcome!') + self.project = project + self.principal = principal + self.role = role + self.invite_message = invite_message + + super() + end + + def run_all_steps + expect_open + + # STEP 1: Project and type + project_step + + # STEP 2: User name + principal_step + + # STEP 3: Role name + role_step + + # STEP 4: Invite message + invitation_step unless placeholder? + + # STEP 5: Confirmation screen + confirmation_step + + # Step 6: Perform invite + click_modal_button 'Send Invitation' + + if invite_user? + expect_text "Invite #{principal.mail} to #{project.name}" + else + expect_text "#{principal_name} was invited!" + end + + text = + case principal + when User + "The user can now log in to access #{project.name}" + when PlaceholderUser + "The placeholder can now be used in #{project.name}" + when Group + "The group is now a part of #{project.name}" + else + raise ArgumentError, "Wrong type" + end + + expect_text text + + # Close + click_modal_button 'Continue' + expect_closed + end + + def project_step(next_step: true) + expect_title 'Invite user' + autocomplete project.name + select_type type + + click_next if next_step + end + + def principal_step(next_step: true) + if invite_user? + autocomplete principal_name, select_text: "Invite: #{principal_name}" + else + autocomplete principal_name + end + + click_next if next_step + end + + def role_step(next_step: true) + autocomplete role.name + + click_next if next_step + end + + def invitation_step(next_step: true) + invitation_message invite_message + click_modal_button 'Review Invitation' if next_step + end + + def confirmation_step + within_modal do + expect(page).to have_text project.name + expect(page).to have_text principal_name + expect(page).to have_text role.name + expect(page).to have_text invite_message unless placeholder? + end + end + + def autocomplete(query, select_text: query) select_autocomplete modal_element.find('.ng-select-container'), query: query, + select_text: select_text, results_selector: 'body' end @@ -45,10 +141,8 @@ module Components end end - def next - within_modal do - click_button 'Next' - end + def click_next + click_modal_button 'Next' end def invitation_message(text) @@ -56,6 +150,25 @@ module Components find('textarea').set text end end + + def invite_user? + principal.invited? + end + + def placeholder? + principal.is_a?(PlaceholderUser) + end + + def principal_name + if invite_user? + principal.mail + else + principal.name + end + end + def type + principal.model_name.human + end end end end diff --git a/spec/support/shared/with_ee.rb b/spec/support/shared/with_ee.rb index 66980abb826..71ae9923c4a 100644 --- a/spec/support/shared/with_ee.rb +++ b/spec/support/shared/with_ee.rb @@ -53,6 +53,9 @@ RSpec.configure do |config| .with(k) .and_return true end + + # Also disable banners to signal the frontend we're on EE + allow(EnterpriseToken).to receive(:show_banners?).and_return(allowed.empty?) end end end