Directivas compartidas
src/app/shared/directives/ tiene 13 directivas (más LowercasePipe, que en realidad es una directiva y vive en shared/pipes, ver Pipes). Resumen general del módulo en Compartido; permisos en Auth y RBAC y Guards.
:::caution Solo dos están declaradas
SharedModule declara y exporta únicamente ShowWithPermissionDirective y ShowForUserTypesDirective. Las otras once directivas (más LowercasePipe) no están declaradas en ningún NgModule ni son standalone, y ningún template las usa: son código muerto hasta que alguien las declare. Se documentan por completitud y para evitar reimplementarlas.
:::
Tabla de selectores
| Clase | Selector (se escribe así en la plantilla) | Archivo | Declarada | Usada en plantillas |
|---|---|---|---|---|
ShowWithPermissionDirective | *appShowWithPermission | show-with-permission.directive.ts | si | 80 usos en 28 plantillas |
ShowForUserTypesDirective | *appHideForUserTypes (nombre inverso al de la clase) | show-for-user-types.directive.ts | si | 3 plantillas |
ClickOutsideDirective | [clickOutside] | click-out-side.directive.ts | no | no |
CustomBadgeDirective | [badgeStyler] | custom-badge.directive.ts | no | no |
DecimalInputDirective | [sharedDecimalInput] | decimal-input.directive.ts | no | no |
DisableKeyPressDirective | [disableKeyPress] | disable-key-press.directive.ts | no | no |
MaxCharactersDirective | [sharedMaxCharacters] | max-characters.directive.ts | no | no |
OnlyAlphabetsDirective | [onlyAlphabets] | only-alphabets.directive.ts | no | no |
OnlyNumbersDirective | [onlyNumbers] | only-numbers.directive.ts | no | no |
OnlyNumbersWhitTwoDecimalDirective | [appOnlyNumbersWhitTwoDecimal] | only-numbers-whit-two-decimal.directive.ts | no | no |
RequiredAsteriskDirective | [appRequiredAsterisk] | required-asterisk.directive.ts | no | no |
RestrictNegativeDirective | [restrictNegative] | restrict-negative.directive.ts | no | no |
ValidationBorderDirective | [border-validation] | validation-border.directive.ts | no | no |
Directivas estructurales de visibilidad
*appShowWithPermission
Muestra el contenido solo si el usuario tiene el permiso. Verifica contra MyPermissionsService (conjunto plano de acrónimos, caché de 10 s).
| Input | Tipo | Efecto |
|---|---|---|
appShowWithPermission | string o string[] | Un acrónimo exige ese permiso (hasPermission); un array exige cualquiera (hasAnyPermission) |
appShowWithPermissionElse | TemplateRef o null | Plantilla alternativa cuando se deniega |
Comportamiento:
- Deny-by-default: la vista no se crea hasta que la comprobación resuelve. Valor vacío,
nullo array vacío se renderizan como denegado (se muestra solo elelse, si existe). - En cada evaluación hace
clear()y recrea la vista; no hay vistas duplicadas. - Las comprobaciones se completan tras una emisión: si el conjunto de permisos cambia después, una vista ya creada no se actualiza hasta que se reasigne el input.
- No hay equivalente en
ng-containersin permiso: las plantillas usan<ng-container *appShowWithPermission="...">.
Ejemplos reales:
<!-- contacts-table.component.html: un acrónimo -->
<ng-container *appShowWithPermission="'CONTACT_CREATE'">
<button class="btn btn-outline-secondary" type="button" (click)="openModal(BUTTON_ACTIONS.ADD)">
...
</ng-container>
<!-- labels-panel.component.html: cualquiera de dos -->
<ng-container *appShowWithPermission="['CONTACT_UPDATE', 'LABEL_UPDATE']">
<button class="lp__btn lp__btn--primary" type="button" (click)="save()">
...
</ng-container>
Con alternativa (soportado y cubierto por spec, pero no usado en ninguna plantilla):
<button *appShowWithPermission="'CONTACT_DELETE'; else sinPermiso">Borrar</button>
<ng-template #sinPermiso><span>Sin permiso</span></ng-template>
Dónde se usa (usos por área): whaticket 25, connections 14, access-control 13, internal-chat 5, contacts 4, settings 4, departments 3, labels 3, users 3, sidebar 3, quick-responses 2, locked-tickets 1. Solo labels-panel.component.html usa la forma de array. El sidebar (shared/components/sidebar/sidebar.component.html) la aplica a menuItem.permission, childrenItem.permission y childrenSubItem.permission (Menu.permission admite string o array, ver core/interfaces/ui/nav.interface.ts) solo cuando el ítem declara permission; sin permiso declarado no hay compuerta. La lista de acrónimos por pantalla está en Auth y RBAC.
Specs: show-with-permission.directive.spec.ts (5 casos: permitido, denegado, oculto mientras la comprobación está pendiente, else, array con cualquiera) y sidebar.component.permission-gating.spec.ts (usa la directiva real para filtrar el menú).
La directiva oculta UI pero no protege datos ni endpoints: el backend responde 403 (ver Interceptores).
*appHideForUserTypes
Clase ShowForUserTypesDirective. Oculta el contenido a ciertos userType; admin siempre lo ve.
| Input | Valor | Lista de denegados |
|---|---|---|
appHideForUserTypes | sin valor, undefined, null o cadena vacía | DEFAULT_ROLES_TO_HIDE_LIST: agent, collaboratorAgent, collaboratorOperator |
| cadena no vacía | solo ese tipo | |
| array | exactamente esos tipos (un array vacío no oculta a nadie) |
Comportamiento:
- Se suscribe a
GlobalService.profile, normalizauserType(trimy minúsculas) y compara contra la lista en minúsculas. - Si el tipo es
adminsiempre muestra, aunque estuviera en la lista. - Crea o destruye la vista de forma idempotente (
ensureShownyensureHidden); reacciona si el perfil cambia. - A diferencia de
*appShowWithPermission, muestra por defecto: antes de que el perfil emita,currentUserTypees''(no está en la lista) y el contenido se renderiza. En ese intervalo un agente podría ver un instante el contenido restringido.
<!-- new-chat-modal.component.html: crear grupo solo para tipos no restringidos -->
<button *appHideForUserTypes class="btn btn-link" type="button" (click)="openCreateGroup()">
<!-- sidebar.component.html: ítem de menú -->
<ng-container *appHideForUserTypes="menuItem.hideForUserTypes ?? []">
Dónde se usa: internal-chat/components/conversation-list (botón de la cabecera), internal-chat/components/new-chat-modal (crear grupo) y el sidebar.
Gotcha del sidebar: pasa menuItem.hideForUserTypes ?? []. Como [] significa "no ocultar a nadie", nunca se aplica la lista por defecto en el menú. Además ningún ítem de nav.constant.ts define hideForUserTypes, así que el filtro por tipo en el menú está inactivo; el filtrado real es por permission.
Specs: show-for-user-types.directive.spec.ts (6 casos: oculta a denegados, muestra a no denegados, admin siempre visible, lista vacía, lista por defecto, reactividad al cambiar el perfil).
Restricción de entrada de teclado
Todas se aplican sobre un input. Ninguna está declarada hoy.
[onlyNumbers] (OnlyNumbersDirective)
keypress: devuelvetruesolo parakeyCode8 y 48-57 (dígitos); cualquier otra tecla (incluido Enter) se cancela.paste: cancela el pegado si el texto no cumple^\d+$.- Fija
autocomplete="off"conHostBinding. - Usa
keyCode, API deprecada.
[onlyAlphabets] (OnlyAlphabetsDirective)
keypress: aceptakeyCode8, 65-90 y 97-122 (letras ASCII). Bloquea espacios, acentos yñ, lo que la hace poco apta para nombres en español.- No controla el pegado. Fija
autocomplete="off".
[sharedDecimalInput] (DecimalInputDirective)
Input sharedDecimalInput: { intDigs: number; decDigs: number }, por defecto { intDigs: 2, decDigs: 2 }. Ejemplo: sharedDecimalInput con enteros 6 y decimales 2.
keydown: solo deja pasar dígitos, Backspace, Delete, flechas, Tab, un único.y un-si el valor no empieza ya por-. Todo lo demás se cancela, incluidos Ctrl+C, Ctrl+V, Ctrl+A y Enter.inputypaste(diferido consetTimeout): valida con la regex^-?\d{0,intDigs}(\.\d{0,decDigs})?$; si falla, recorta el último carácter y redisparainput.
[appOnlyNumbersWhitTwoDecimal] (OnlyNumbersWhitTwoDecimalDirective)
- Requiere
NgControlen el mismo elemento (formControlName,formControlongModel), porque leengControl.control.value. keydown: ignora teclas especiales (Backspace, Tab, End, Home, flechas, Delete). Calculavalor actual + teclay bloquea si no cumple^(-)?\d+(\.\d{0,2})?$. Un-se admite solo en campo vacío. No tiene en cuenta la posición del cursor.- Un
.como primer carácter se rechaza (debe haber un dígito antes). Ctrl+V por teclado también queda bloqueado; el pegado con menú contextual pasa por el handlerpaste, que aplica la misma regex. current.concat(...)supone que el valor es string: con un control numérico lanzaríaTypeError.
[restrictNegative] (RestrictNegativeDirective)
keydown: bloquea-,eyE.input: si el valor empieza por-, es menor que 0 o no es numérico, vacía el campo y redisparainput. Pensada paratype="number".
[sharedMaxCharacters] (MaxCharactersDirective)
Input sharedMaxCharacters: number (por defecto 0).
keydown: con la longitud en el límite, cancela teclas imprimibles (key.length === 1sin Ctrl, Meta ni Alt); Backspace, flechas y atajos siguen funcionando.inputypaste(diferido): recorta conslice(0, max).- Con el valor por defecto 0 bloquea toda escritura: siempre hay que pasar el número. El recorte asigna
input.valuesin redispararinput, por lo que el modelo del formulario puede conservar la versión larga hasta el siguiente evento.
[disableKeyPress] (DisableKeyPressDirective)
Cancela todo keypress y fija autocomplete="off". Convierte el input en no editable por teclado (no por pegado ni por arrastre).
Utilidades de UI y formularios
(clickOutside) (ClickOutsideDirective)
Output clickOutside: EventEmitter<any>. Escucha document:click y emite (sin payload) cuando el event.target no está contenido en el elemento anfitrión. Cada instancia registra su propio listener global.
<div (clickOutside)="cerrarMenu()"> ... </div>
Gotcha: si el panel se abre con un clic que ocurre fuera del anfitrión, ese mismo clic puede emitir clickOutside al propagarse hasta document.
[badgeStyler] (CustomBadgeDirective)
Inputs obligatorios (required: true): badgeValue: string, badgeEnum: { [key: string]: string } y badgeStyles: Record<string, BadgeStyleConfig> (BadgeStyleConfig extiende TranslationsPerLanguage y añade color). En ngOnChanges busca la clave del enum cuyo valor coincide con badgeValue, toma badgeStyles[valor], aplica color y un backgroundColor rgba con opacidad 0.1 (convierte #abc y #aabbcc). Si no hay coincidencia o el color no es hexadecimal válido, no pinta (con hex inválido el fondo queda en cadena vacía).
<span badgeStyler [badgeValue]="row.status" [badgeEnum]="STATUS_ENUM" [badgeStyles]="STATUS_STYLES"></span>
[appRequiredAsterisk] (RequiredAsteriskDirective)
Pensada para el contenedor de un campo (div con label y control). Requiere un ControlContainer padre (formGroup).
- Busca dentro del anfitrión el primer
input, select, textarea, ng-selecty lee su atributoformControlNamepara obtener elAbstractControl. - Añade al
labelunspan.required-asteriskcon*. - Añade un
div.required-error-message(oculto) conwords.thisFieldIsRequired(traducido una vez coninstant). - Al
blurdel control marcatouchedy revalida; con cadastatusChangesmuestra el mensaje siinvalid && touched.
Los estilos .required-asterisk y .required-error-message están en assets/scss/components/_form-input.scss.
Gotchas: el asterisco aparece siempre que se encuentre un control, aunque no tenga Validators.required; el mensaje se muestra para cualquier error, no solo required; usa formControlName literal (no sirve con [formControl]); la suscripción a statusChanges no se cancela.
[border-validation] (ValidationBorderDirective)
Selector con guion, sin camelCase. Requiere NgControl en el mismo elemento. Se suscribe a statusChanges y valueChanges del control (con ngModel, espera un tick con setTimeout a que exista control) y delega en ValidationService (core/services/ui/validation.service.ts):
- Borde y sombra rojos (
#dc3545) siisInvalid && isTouched(dirty || touched). - Borde y sombra verdes (
#28a745) si es válido y no está vacío. - Si no, limpia
borderybox-shadow.
ValidationService.getValidationState considera inválido, además de control.invalid, un campo con atributo HTML required vacío o un valor menor al atributo min. "Vacío" incluye valores falsos o <= 0, por lo que un 0 legítimo se trata como vacío.
Resumen de uso real
Solo las dos directivas estructurales están en producción. Para activar cualquiera de las demás habría que declararla (y exportarla) en SharedModule, o convertirla a standalone.