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');
}