libs/acl/angular/src/lib/acl-tag-directive/acl-tag.directive.ts

Description

Overloaded directive which acts as A) a tagging mechanism to create an acl hierarchy (aclTag="my-tag") B) people can also use it as a structural directive (*aclTag="'my-tag'") to hide resources depending on their granted acl view action

Implements

OnInit OnDestroy

Example

Metadata

Relationships

Index

Properties
Methods
Inputs
Accessors

Constructor

constructor()

Inputs

aclTagHint
Type : boolean
Default value : false
aclTag
Type : string
Required :  true
aclTagTransient
Type : boolean
Default value : false

Methods

render
render()
Returns : void
renderComponent
renderComponent()
Returns : void
renderHint
renderHint()
Returns : void
renderWhenAclViewGranted
renderWhenAclViewGranted()
Returns : void

Properties

currentViewRef
Type : EmbeddedViewRef<unknown>
destroyed$
Type : unknown
Default value : new Subject<void>()
inspectorServiceOverrideShowHint
Type : unknown
Default value : false

Accessors

tagName
gettagName()
showHint
getshowHint()
import { AclTag } from '@allianz/taly-acl';
import {
  Directive,
  EmbeddedViewRef,
  effect,
  untracked,
  Injector,
  OnDestroy,
  OnInit,
  Renderer2,
  TemplateRef,
  ViewContainerRef,
  inject,
  input
} from '@angular/core';
import { BehaviorSubject, Subject } from 'rxjs';
import { map, switchMap, takeUntil, tap } from 'rxjs/operators';
import {
  AclHintDecoration,
  AclHintOverlayService,
  AclHintTarget,
  findFirstElementNode
} from '../acl-hint/public-api';
import { AclInspectorService } from '../inspector/inspector.service';
import { createAsyncAclTagProviders } from '../providers/public-api';
import { AclService } from '../services/acl.service';
import { ACL_TAG_NAME_ASYNC_TOKEN, ACL_TAG_TOKEN, ACL_TAG_TRANSIENT } from '../tokens/public-api';

const HINT_OUTLINE_COLOR = '#008ed6';

/**
 * Overloaded directive which acts as
 * A) a tagging mechanism to create an acl hierarchy (aclTag="my-tag")
 * B) people can also use it as a structural directive (*aclTag="'my-tag'")
 * to hide resources depending on their granted acl view action
 */
@Directive({
  selector: '[aclTag]',
  providers: [createAsyncAclTagProviders()],
  standalone: false
})
export class AclTagDirective implements OnInit, OnDestroy {
  private aclTagUpdate$ = inject<BehaviorSubject<string>>(ACL_TAG_NAME_ASYNC_TOKEN, {
    optional: true,
    self: true
  });
  private aclTagTransient$ = inject<BehaviorSubject<boolean>>(ACL_TAG_TRANSIENT, {
    optional: true,
    host: true
  });
  private aclTag = inject<AclTag>(ACL_TAG_TOKEN, { optional: true, self: true });
  private templateRef = inject<TemplateRef<unknown>>(TemplateRef, { optional: true });
  private viewContainer = inject(ViewContainerRef, { optional: true });
  private aclService = inject<AclService>(AclService);
  private aclInspectorService = inject<AclInspectorService>(AclInspectorService);
  private hintOverlay = inject(AclHintOverlayService);
  private renderer = inject(Renderer2);
  private injector = inject(Injector);

  private show = false;
  private componentIsVisible = false;

  private hintOrigin?: Element;
  private decoratedOrigin?: Element;
  private hintListeners: (() => void)[] = [];
  private hintTargetRef?: AclHintTarget;

  destroyed$ = new Subject<void>();
  readonly hint = input(false, { alias: 'aclTagHint' });
  readonly transient = input(false, { alias: 'aclTagTransient' });
  readonly tag = input.required<string>({ alias: 'aclTag' });
  inspectorServiceOverrideShowHint = false;

  currentViewRef!: EmbeddedViewRef<unknown>;

  private tagAlreadyInitialized = false;
  private transientAlreadyInitialized = false;

  constructor() {
    if (!this.aclTag) {
      throw new Error('Could not find any Acl Tag');
    }

    this.aclInspectorService.showAclHints$
      .pipe(
        takeUntil(this.destroyed$),
        tap((value) => {
          this.inspectorServiceOverrideShowHint = value;
          this.render();
        })
      )
      .subscribe();

    effect(() => {
      const tag = this.tag();
      if (!this.tagAlreadyInitialized) {
        this.tagAlreadyInitialized = true;
        return;
      }
      untracked(() => {
        this.updateTagName(tag);
        this.render();
      });
    });

    effect(() => {
      const transient = this.transient();
      if (!this.transientAlreadyInitialized) {
        this.transientAlreadyInitialized = true;
        return;
      }
      untracked(() => {
        this.updateTransient(transient);
        this.render();
      });
    });
  }

  get tagName() {
    return this.tag();
  }

  get showHint() {
    return this.hint() || this.inspectorServiceOverrideShowHint;
  }

  ngOnInit(): void {
    this.updateTagName(this.tagName);

    /**
     * Only in case this directive is used as a structural directive:
     * Subscribe for any acl key change as the key is async by design
     * to prevent any circular dependencies. See ACL_TAG_NAME_ASYNC_TOKEN & aclTagProvider()
     */
    if (this.isStructuralDirective()) {
      this.renderWhenAclViewGranted();
    } else {
      this.renderHint();
    }
  }

  renderWhenAclViewGranted() {
    this.aclTag?.aclKey$
      .pipe(
        map((item) => {
          return item.aclKey;
        }),
        switchMap((resourceName) => this.aclService.isHidden$(resourceName)),
        tap((isHidden) => {
          this.show = !isHidden;
          this.render();
        }),
        takeUntil(this.destroyed$)
      )
      .subscribe();

    this.render();
  }

  renderComponent() {
    if (!this.viewContainer) {
      throw new Error('Could not find any ViewContainerRef');
    }
    if (!this.show && this.componentIsVisible) {
      const index = this.viewContainer.indexOf(this.currentViewRef);
      if (index !== -1) {
        if (this.hintTargetRef && this.hintOrigin) {
          this.hintOverlay.originDetached(this.hintTargetRef);
        }
        this.viewContainer.remove(index);
        this.componentIsVisible = false;
      }
    } else if (this.show && !this.componentIsVisible) {
      if (!this.templateRef) {
        throw new Error('Could not find any TemplateRef');
      }
      this.currentViewRef = this.viewContainer.createEmbeddedView(this.templateRef);
      this.componentIsVisible = true;
    }
  }

  renderHint() {
    if (!this.showHint) {
      this.releaseHint();
      return;
    }

    const origin = this.resolveOrigin();
    if (!origin) {
      // The target stays registered, so a hint whose element disappeared survives until the
      // pointer leaves it (see `originDetached`).
      this.unlistenToOrigin();
      return;
    }

    const target = this.hintTarget();
    if (this.hintOrigin !== origin) {
      this.unlistenToOrigin();
      this.hintOrigin = origin;
      target.origin = origin;
      this.hintListeners = [
        this.renderer.listen(origin, 'pointerenter', () => this.hintOverlay.enter(target)),
        this.renderer.listen(origin, 'pointerleave', (event: PointerEvent) =>
          this.hintOverlay.leave(target, event.relatedTarget)
        )
      ];
      // Switching hints on under a resting pointer never produces a `pointerenter`, so the element
      // is asked whether it is hovered already instead of waiting for one.
      if (isHovered(origin)) {
        this.hintOverlay.enter(target);
      }
    }
    // Also for an unchanged element: a hint frozen for it may still be waiting to re-anchor.
    this.hintOverlay.originAttached(target);
  }

  render() {
    this.renderComponent();
    this.renderHint();
  }

  ngOnDestroy(): void {
    this.releaseHint();
    this.aclTagUpdate$?.complete();
    this.aclTagTransient$?.complete();
    this.destroyed$.next();
  }

  /**
   * The overlay target representing this ACL tag. Created once, so the service can keep
   * re-anchoring a re-appearing element to the hint it froze for it.
   */
  private hintTarget(): AclHintTarget {
    this.hintTargetRef ??= {
      origin: document.createComment('acl-hint-placeholder') as unknown as Element,
      aclPath: () => this.aclTag?.aclKey ?? '',
      decorate: (decoration) => this.decorateOrigin(decoration),
      injector: this.injector
    };
    return this.hintTargetRef;
  }

  private unlistenToOrigin() {
    this.hintListeners.forEach((unlisten) => unlisten());
    this.hintListeners = [];
    this.hintOrigin = undefined;
  }

  private releaseHint() {
    this.unlistenToOrigin();
    this.undecorateOrigin();
    if (this.hintTargetRef) {
      this.hintOverlay.release(this.hintTargetRef);
      this.hintTargetRef = undefined;
    }
  }

  private decorateOrigin(decoration: AclHintDecoration) {
    if (decoration === 'none') {
      this.undecorateOrigin();
      return;
    }

    const origin = this.hintOrigin;
    if (!origin?.isConnected) {
      return;
    }
    if (this.decoratedOrigin !== origin) {
      this.undecorateOrigin();
    }

    const style = decoration === 'active' ? 'solid' : 'dashed';
    this.renderer.setStyle(origin, 'outline', `2px ${style} ${HINT_OUTLINE_COLOR}`);
    this.renderer.setStyle(origin, 'outline-offset', '-2px');
    this.decoratedOrigin = origin;
  }

  private undecorateOrigin() {
    const origin = this.decoratedOrigin;
    this.decoratedOrigin = undefined;
    if (!origin) {
      return;
    }
    this.renderer.removeStyle(origin, 'outline');
    this.renderer.removeStyle(origin, 'outline-offset');
  }

  /**
   * The element the hints can be anchored to. As a structural directive the host is a comment
   * node, so the first element of the rendered content is used instead.
   */
  private resolveOrigin() {
    if (!this.isStructuralDirective()) {
      const host = this.viewContainer?.element.nativeElement;
      return findFirstElementNode(host ? [host] : []);
    }
    return this.componentIsVisible ? findFirstElementNode(this.currentViewRef.rootNodes) : null;
  }

  private updateTagName(value: string) {
    this.aclTagUpdate$?.next(value);
  }

  private updateTransient(transient: boolean) {
    this.aclTagTransient$?.next(transient);
  }

  private isStructuralDirective() {
    return null !== this.templateRef;
  }
}

/** Whether the pointer already rests on the element, which only CSS keeps a record of. */
function isHovered(element: Element) {
  return element.isConnected && element.matches(':hover');
}

results matching ""

    No results matching ""