diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index c5655d7a0..c6cc26b60 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -18,7 +18,7 @@ "apps.archieveWarning": "Once you archive an app, there is no going back. Please be certain.", "apps.archiveFailed": "Failed to archive app. Please reload.", "apps.create": "Create App", - "apps.createBlankApp": "New App.", + "apps.createBlankApp": "New App", "apps.createBlankAppDescription": "Create a new blank app without content and schemas.", "apps.createBlogApp": "New Blog Sample", "apps.createBlogAppDescription": "Start with our ready to use blog.", @@ -35,6 +35,10 @@ "apps.generalSettingsDangerZone": "General", "apps.image": "Image", "apps.imageDrop": "Drop to upload", + "apps.leave": "Leave app", + "apps.leaveConfirmText": "Leave app.", + "apps.leaveConfirmTitle": "Do you really want to leave this app?", + "apps.leaveFailed": "Failed to leave app. Please reload.", "apps.listPageTitle": "Apps", "apps.loadFailed": "Failed to load apps. Please reload.", "apps.removeImage": "Remove image", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index 4176a6bf6..2d31410b0 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -35,6 +35,10 @@ "apps.generalSettingsDangerZone": "Generale", "apps.image": "Immagine", "apps.imageDrop": "Trascina il file per caricare", + "apps.leave": "Leave app", + "apps.leaveConfirmText": "Leave app.", + "apps.leaveConfirmTitle": "Do you really want to leave this app?", + "apps.leaveFailed": "Failed to leave app. Please reload.", "apps.listPageTitle": "App", "apps.loadFailed": "Non è stato possibile caricare le App. Per favore ricarica.", "apps.removeImage": "Rimuovi l'immagine", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index a90523bcb..60868b5b1 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -35,6 +35,10 @@ "apps.generalSettingsDangerZone": "Algemeen", "apps.image": "Afbeelding", "apps.imageDrop": "Zet neer om te uploaden", + "apps.leave": "Leave app", + "apps.leaveConfirmText": "Leave app.", + "apps.leaveConfirmTitle": "Do you really want to leave this app?", + "apps.leaveFailed": "Failed to leave app. Please reload.", "apps.listPageTitle": "Apps", "apps.loadFailed": "Laden van apps is mislukt. Laad opnieuw.", "apps.removeImage": "Afbeelding verwijderen", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index c5655d7a0..a1c609f34 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -18,7 +18,7 @@ "apps.archieveWarning": "Once you archive an app, there is no going back. Please be certain.", "apps.archiveFailed": "Failed to archive app. Please reload.", "apps.create": "Create App", - "apps.createBlankApp": "New App.", + "apps.createBlankApp": "New App", "apps.createBlankAppDescription": "Create a new blank app without content and schemas.", "apps.createBlogApp": "New Blog Sample", "apps.createBlogAppDescription": "Start with our ready to use blog.", @@ -35,6 +35,10 @@ "apps.generalSettingsDangerZone": "General", "apps.image": "Image", "apps.imageDrop": "Drop to upload", + "apps.leave": "Leave app", + "apps.leaveConfirmTitle": "Leave app.", + "apps.leaveConfirmText": "Do you really want to leave this app?", + "apps.leaveFailed": "Failed to leave app. Please reload.", "apps.listPageTitle": "Apps", "apps.loadFailed": "Failed to load apps. Please reload.", "apps.removeImage": "Remove image", diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs index 337085a18..1672a30a9 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs @@ -13,7 +13,10 @@ using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Invitation; using Squidex.Domain.Apps.Entities.Apps.Plans; +using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Security; +using Squidex.Infrastructure.Translations; using Squidex.Shared; using Squidex.Shared.Users; using Squidex.Web; @@ -86,6 +89,28 @@ namespace Squidex.Areas.Api.Controllers.Apps return CreatedAtAction(nameof(GetContributors), new { app }, response); } + /// + /// Remove yourself. + /// + /// The name of the app. + /// + /// 200 => User removed from app. + /// 404 => Contributor or app not found. + /// + [HttpDelete] + [Route("apps/{app}/contributors/me/")] + [ProducesResponseType(typeof(ContributorsDto), 200)] + [ApiPermission] + [ApiCosts(1)] + public async Task DeleteMyself(string app) + { + var command = new RemoveContributor { ContributorId = UserId() }; + + var response = await InvokeCommandAsync(command); + + return Ok(response); + } + /// /// Remove contributor. /// @@ -123,6 +148,18 @@ namespace Squidex.Areas.Api.Controllers.Apps } } + private string UserId() + { + var subject = User.OpenIdSubject(); + + if (string.IsNullOrWhiteSpace(subject)) + { + throw new DomainForbiddenException(T.Get("common.httpOnlyAsUser")); + } + + return subject; + } + private Task GetResponseAsync(IAppEntity app, bool invited) { return ContributorsDto.FromAppAsync(app, Resources, userResolver, appPlansProvider, invited); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs index 2a6563ce6..eb9c2ee84 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs @@ -29,6 +29,8 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models { public sealed class AppDto : Resource { + private static readonly JsonObject EmptyObject = JsonValue.Object(); + /// /// The name of the app. /// @@ -69,7 +71,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// /// The permission level of the user. /// - public IEnumerable Permissions { get; set; } + public IEnumerable Permissions { get; set; } = Array.Empty(); /// /// Indicates if the user can access the api. @@ -96,26 +98,27 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// The properties from the role. /// [LocalizedRequired] - public JsonObject RoleProperties { get; set; } + public JsonObject RoleProperties { get; set; } = EmptyObject; public static AppDto FromApp(IAppEntity app, string userId, bool isFrontend, IAppPlansProvider plans, Resources resources) { - var permissions = GetPermissions(app, userId, isFrontend); - - var result = SimpleMapper.Map(app, new AppDto()); + var result = SimpleMapper.Map(app, new AppDto + { + PlanName = plans.GetPlanForApp(app).Plan.Name + }); - result.Permissions = permissions.ToIds(); + var permissions = PermissionSet.Empty; - result.SetPlan(app, plans, resources, permissions); - result.SetImage(app, resources); + var isContributor = false; if (app.Contributors.TryGetValue(userId, out var roleName) && app.Roles.TryGet(app.Name, roleName, isFrontend, out var role)) { + isContributor = true; + result.RoleProperties = role.Properties; - } - else - { - result.RoleProperties = JsonValue.Object(); + result.Permissions = permissions.ToIds(); + + permissions = role.Permissions; } if (resources.Includes(P.ForApp(P.AppContents, app.Name), permissions)) @@ -123,44 +126,29 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models result.CanAccessContent = true; } - return result.CreateLinks(resources, permissions); - } - - private static PermissionSet GetPermissions(IAppEntity app, string userId, bool isFrontend) - { - var permissions = new List(); - - if (app.Contributors.TryGetValue(userId, out var roleName) && app.Roles.TryGet(app.Name, roleName, isFrontend, out var role)) + if (resources.IsAllowed(P.AppPlansChange, app.Name, additional: permissions)) { - permissions.AddRange(role.Permissions); + result.PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name; } - return new PermissionSet(permissions); + return result.CreateLinks(app, resources, permissions, isContributor); } - private void SetPlan(IAppEntity app, IAppPlansProvider plans, Resources resources, PermissionSet permissions) + private AppDto CreateLinks(IAppEntity app, Resources resources, PermissionSet permissions, bool isContributor) { - if (resources.IsAllowed(P.AppPlansChange, app.Name, additional: permissions)) - { - PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name; - } + var values = new { app = Name }; - PlanName = plans.GetPlanForApp(app).Plan.Name; - } + AddGetLink("ping", resources.Url(x => nameof(x.GetAppPing), values)); - private void SetImage(IAppEntity app, Resources resources) - { if (app.Image != null) { AddGetLink("image", resources.Url(x => nameof(x.GetImage), new { app = app.Name })); } - } - private AppDto CreateLinks(Resources resources, PermissionSet permissions) - { - var values = new { app = Name }; - - AddGetLink("ping", resources.Url(x => nameof(x.GetAppPing), values)); + if (isContributor) + { + AddDeleteLink("leave", resources.Url(x => nameof(x.DeleteMyself), values)); + } if (resources.IsAllowed(P.AppDelete, Name, additional: permissions)) { @@ -172,13 +160,6 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models AddPutLink("update", resources.Url(x => nameof(x.UpdateApp), values)); } - if (resources.IsAllowed(P.AppUpdateImage, Name, additional: permissions)) - { - AddPostLink("image/upload", resources.Url(x => nameof(x.UploadImage), values)); - - AddDeleteLink("image/delete", resources.Url(x => nameof(x.DeleteImage), values)); - } - if (resources.IsAllowed(P.AppAssetsRead, Name, additional: permissions)) { AddGetLink("assets", resources.Url(x => nameof(x.GetAssets), values)); @@ -244,6 +225,13 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models AddPostLink("assets/create", resources.Url(x => nameof(x.PostSchema), values)); } + if (resources.IsAllowed(P.AppUpdateImage, Name, additional: permissions)) + { + AddPostLink("image/upload", resources.Url(x => nameof(x.UploadImage), values)); + + AddDeleteLink("image/delete", resources.Url(x => nameof(x.DeleteImage), values)); + } + return this; } } diff --git a/frontend/app/features/apps/declarations.ts b/frontend/app/features/apps/declarations.ts index cf67865fb..9a4a23ce2 100644 --- a/frontend/app/features/apps/declarations.ts +++ b/frontend/app/features/apps/declarations.ts @@ -5,6 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ +export * from './pages/app.component'; export * from './pages/apps-page.component'; export * from './pages/news-dialog.component'; export * from './pages/onboarding-dialog.component'; diff --git a/frontend/app/features/apps/module.ts b/frontend/app/features/apps/module.ts index baa14ada4..4d4f9631f 100644 --- a/frontend/app/features/apps/module.ts +++ b/frontend/app/features/apps/module.ts @@ -8,7 +8,7 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { SqxFrameworkModule, SqxSharedModule } from '@app/shared'; -import { AppsPageComponent, NewsDialogComponent, OnboardingDialogComponent } from './declarations'; +import { AppComponent, AppsPageComponent, NewsDialogComponent, OnboardingDialogComponent } from './declarations'; const routes: Routes = [ { @@ -24,6 +24,7 @@ const routes: Routes = [ SqxSharedModule ], declarations: [ + AppComponent, AppsPageComponent, NewsDialogComponent, OnboardingDialogComponent diff --git a/frontend/app/features/apps/pages/app.component.html b/frontend/app/features/apps/pages/app.component.html new file mode 100644 index 000000000..ca7926d8e --- /dev/null +++ b/frontend/app/features/apps/pages/app.component.html @@ -0,0 +1,43 @@ +
+ +
\ No newline at end of file diff --git a/frontend/app/features/apps/pages/app.component.scss b/frontend/app/features/apps/pages/app.component.scss new file mode 100644 index 000000000..6b5955faa --- /dev/null +++ b/frontend/app/features/apps/pages/app.component.scss @@ -0,0 +1,11 @@ +.btn { + @include absolute(1rem, 1rem); +} + +.card-body { + position: relative; +} + +.card-title { + padding-right: 2rem; +} \ No newline at end of file diff --git a/frontend/app/features/apps/pages/app.component.ts b/frontend/app/features/apps/pages/app.component.ts new file mode 100644 index 000000000..d47129aec --- /dev/null +++ b/frontend/app/features/apps/pages/app.component.ts @@ -0,0 +1,28 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { AppDto, fadeAnimation, ModalModel } from '@app/shared'; + +@Component({ + selector: 'sqx-app', + styleUrls: ['./app.component.scss'], + templateUrl: './app.component.html', + animations: [ + fadeAnimation + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AppComponent { + @Input() + public app: AppDto; + + @Output() + public leave = new EventEmitter(); + + public dropdown = new ModalModel(); +} \ No newline at end of file diff --git a/frontend/app/features/apps/pages/apps-page.component.html b/frontend/app/features/apps/pages/apps-page.component.html index 8f5f90d63..7cea6473d 100644 --- a/frontend/app/features/apps/pages/apps-page.component.html +++ b/frontend/app/features/apps/pages/apps-page.component.html @@ -15,33 +15,9 @@

{{ 'apps.empty' | sqxTranslate }}

-
-
-
-
- -
- -
-
-
+ + diff --git a/frontend/app/features/apps/pages/apps-page.component.scss b/frontend/app/features/apps/pages/apps-page.component.scss index 5f89725ff..f2e266012 100644 --- a/frontend/app/features/apps/pages/apps-page.component.scss +++ b/frontend/app/features/apps/pages/apps-page.component.scss @@ -15,80 +15,80 @@ overflow-y: auto; } -.card { - & { - @include hover-visible('.deeplinks', inline); - display: inline-block; - margin-bottom: 1rem; - margin-right: 1rem; - width: 20rem; - } - - &-links { - margin-top: .5rem; - } - - &-left { - padding-right: .75rem; - } +:host ::ng-deep { + .card { + & { + @include hover-visible('.deeplinks', inline); + display: inline-block; + margin-bottom: 1rem; + margin-right: 1rem; + width: 20rem; + } - &-right { - overflow: hidden; - } + &-links { + margin-top: .5rem; + } - &-image { - text-align: center; - } + &-left { + padding-right: .75rem; + } - &-text { - color: $color-text-decent; - font-size: .9rem; - font-weight: normal; - margin-top: .5rem; - } + &-right { + overflow: hidden; + } - &-title { - @include truncate; - color: $color-title; - margin-bottom: 0; - margin-top: 0; - } + &-image { + text-align: center; - &-template { - .card-body { - min-height: 15.5rem; + img { + height: 6rem; + } } - .card-title { - margin-bottom: .75rem; - margin-top: 1rem; + &-text { + color: $color-text-decent; + font-size: .9rem; + font-weight: normal; + margin-top: .5rem; } - } - &-href { - & { - cursor: pointer; + &-title { + @include truncate; + color: $color-title; + margin-bottom: 0; + margin-top: 0; } - &:hover { - @include box-shadow-outer(0, 3px, 16px, .2); - } + &-template { + .card-body { + min-height: 15.5rem; + } - &:focus { - outline: none; + .card-title { + margin-bottom: .75rem; + margin-top: 1rem; + } } - &:hover, - &:focus, - &:active { - text-decoration: none; - } - } -} + &-href { + & { + cursor: pointer; + } -.card-image { - img { - height: 6rem; + &:hover { + @include box-shadow-outer(0, 3px, 16px, .2); + } + + &:focus { + outline: none; + } + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } } } diff --git a/frontend/app/features/apps/pages/apps-page.component.ts b/frontend/app/features/apps/pages/apps-page.component.ts index 03162eced..d50f05459 100644 --- a/frontend/app/features/apps/pages/apps-page.component.ts +++ b/frontend/app/features/apps/pages/apps-page.component.ts @@ -71,6 +71,10 @@ export class AppsPageComponent implements OnInit { this.addAppDialog.show(); } + public leave(app: AppDto) { + this.appsState.leave(app); + } + public trackByApp(_index: number, app: AppDto) { return app.id; } diff --git a/frontend/app/features/content/pages/content/content-page.component.html b/frontend/app/features/content/pages/content/content-page.component.html index 98f4b25ef..c992a5e47 100644 --- a/frontend/app/features/content/pages/content/content-page.component.html +++ b/frontend/app/features/content/pages/content/content-page.component.html @@ -26,9 +26,9 @@ - - + diff --git a/frontend/app/features/content/shared/list/content.component.html b/frontend/app/features/content/shared/list/content.component.html index e8fab4253..f9163e31f 100644 --- a/frontend/app/features/content/shared/list/content.component.html +++ b/frontend/app/features/content/shared/list/content.component.html @@ -22,38 +22,36 @@ - + + + diff --git a/frontend/app/features/schemas/pages/schema/fields/field.component.html b/frontend/app/features/schemas/pages/schema/fields/field.component.html index cddad0626..bf5b3db73 100644 --- a/frontend/app/features/schemas/pages/schema/fields/field.component.html +++ b/frontend/app/features/schemas/pages/schema/fields/field.component.html @@ -41,54 +41,52 @@ - + diff --git a/frontend/app/features/schemas/pages/schema/schema-page.component.html b/frontend/app/features/schemas/pages/schema/schema-page.component.html index af9467156..3ba58ee8a 100644 --- a/frontend/app/features/schemas/pages/schema/schema-page.component.html +++ b/frontend/app/features/schemas/pages/schema/schema-page.component.html @@ -19,35 +19,33 @@ - + + + {{ 'schemas.contextMenuTour' | sqxTranslate }} diff --git a/frontend/app/features/settings/pages/clients/clients-page.component.html b/frontend/app/features/settings/pages/clients/clients-page.component.html index 5178455bf..b21a553cb 100644 --- a/frontend/app/features/settings/pages/clients/clients-page.component.html +++ b/frontend/app/features/settings/pages/clients/clients-page.component.html @@ -22,7 +22,9 @@ - + diff --git a/frontend/app/shared/components/assets/asset-folder.component.html b/frontend/app/shared/components/assets/asset-folder.component.html index a6d0864e7..d9c4eca31 100644 --- a/frontend/app/shared/components/assets/asset-folder.component.html +++ b/frontend/app/shared/components/assets/asset-folder.component.html @@ -8,7 +8,7 @@ {{assetFolder.folderName | sqxTranslate}}
- -
+ diff --git a/frontend/app/shared/components/forms/language-selector.component.html b/frontend/app/shared/components/forms/language-selector.component.html index 06a619277..e24ec3cf3 100644 --- a/frontend/app/shared/components/forms/language-selector.component.html +++ b/frontend/app/shared/components/forms/language-selector.component.html @@ -4,8 +4,8 @@ - - \ No newline at end of file + \ No newline at end of file diff --git a/frontend/app/shared/services/apps.service.spec.ts b/frontend/app/shared/services/apps.service.spec.ts index 326d135b9..22fc907fc 100644 --- a/frontend/app/shared/services/apps.service.spec.ts +++ b/frontend/app/shared/services/apps.service.spec.ts @@ -195,6 +195,25 @@ describe('AppsService', () => { expect(app!).toEqual(createApp(12)); })); + it('should make delete request to leave app', + inject([AppsService, HttpTestingController], (appsService: AppsService, httpMock: HttpTestingController) => { + + const resource: Resource = { + _links: { + delete: { method: 'DELETE', href: '/api/apps/my-app/contributors/me' } + } + }; + + appsService.deleteApp(resource).subscribe(); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/contributors/me'); + + expect(req.request.method).toEqual('DELETE'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush({}); + })); + it('should make delete request to archive app', inject([AppsService, HttpTestingController], (appsService: AppsService, httpMock: HttpTestingController) => { diff --git a/frontend/app/shared/services/apps.service.ts b/frontend/app/shared/services/apps.service.ts index d2b96e28a..6f5a255c4 100644 --- a/frontend/app/shared/services/apps.service.ts +++ b/frontend/app/shared/services/apps.service.ts @@ -195,6 +195,18 @@ export class AppsService { pretifyError('i18n:apps.removeImageFailed')); } + public leaveApp(resource: Resource): Observable { + const link = resource._links['leave']; + + const url = this.apiUrl.buildUrl(link.href); + + return this.http.request(link.method, url).pipe( + tap(() => { + this.analytics.trackEvent('App', 'Left'); + }), + pretifyError('i18n:apps.leaveFailed')); + } + public deleteApp(resource: Resource): Observable { const link = resource._links['delete']; diff --git a/frontend/app/shared/state/apps.state.spec.ts b/frontend/app/shared/state/apps.state.spec.ts index 297207eef..5fb02e864 100644 --- a/frontend/app/shared/state/apps.state.spec.ts +++ b/frontend/app/shared/state/apps.state.spec.ts @@ -163,6 +163,15 @@ describe('AppsState', () => { expect(appsState.snapshot.apps).toEqual([app1, updated]); }); + it('should remove app from snapshot when left', () => { + appsService.setup(x => x.leaveApp(app2)) + .returns(() => of({})).verifiable(); + + appsState.leave(app2).subscribe(); + + expect(appsState.snapshot.apps).toEqual([app1]); + }); + it('should remove app from snapshot when archived', () => { appsService.setup(x => x.deleteApp(app2)) .returns(() => of({})).verifiable(); diff --git a/frontend/app/shared/state/apps.state.ts b/frontend/app/shared/state/apps.state.ts index 87ea46e75..0b4d2afbb 100644 --- a/frontend/app/shared/state/apps.state.ts +++ b/frontend/app/shared/state/apps.state.ts @@ -131,24 +131,36 @@ export class AppsState extends State { shareSubscribed(this.dialogs)); } + public leave(app: AppDto): Observable { + return this.appsService.leaveApp(app).pipe( + tap(() => { + this.removeApp(app); + }), + shareSubscribed(this.dialogs)); + } + public delete(app: AppDto): Observable { return this.appsService.deleteApp(app).pipe( tap(() => { - this.next(s => { - const apps = s.apps.filter(x => x.name !== app.name); - - const selectedApp = - s.selectedApp && - s.selectedApp.id === app.id ? - null : - s.selectedApp; - - return { ...s, apps, selectedApp }; - }); + this.removeApp(app); }), shareSubscribed(this.dialogs)); } + private removeApp(app: AppDto) { + this.next(s => { + const apps = s.apps.filter(x => x.name !== app.name); + + const selectedApp = + s.selectedApp && + s.selectedApp.id === app.id ? + null : + s.selectedApp; + + return { ...s, apps, selectedApp }; + }); + } + private replaceApp(updated: AppDto, app: AppDto) { this.next(s => { const apps = s.apps.replaceBy('id', updated); diff --git a/frontend/app/theme/_common.scss b/frontend/app/theme/_common.scss index 9cd733bea..482fe579c 100644 --- a/frontend/app/theme/_common.scss +++ b/frontend/app/theme/_common.scss @@ -135,20 +135,6 @@ body { } } -.dropdown-options { - & { - display: inline-block; - } - - .dropdown-menu { - @include absolute(100%, 0, auto, auto); - } - - .dropdown-item { - cursor: pointer; - } -} - .dropdown-item { cursor: pointer; }