diff --git a/.eslintrc.js b/.eslintrc.js index ce21309..6f48723 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,7 +6,7 @@ module.exports = { globals: { NodeJS: true, }, - ignorePatterns: ['!**/*'], + ignorePatterns: ['!**/*', 'jest.config.ts'], overrides: [ { files: ['*.ts'], @@ -206,6 +206,7 @@ module.exports = { ], rules: { 'rxjs/no-ignored-subscription': 'error', + '@angular-eslint/prefer-standalone': 'off', '@angular-eslint/directive-selector': [ 'error', { diff --git a/sonar-project.properties b/sonar-project.properties index 0065dbe..2b69e11 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,2 +1,2 @@ sonar.projectKey=common-ui -sonar.qualitygate.wait=true \ No newline at end of file +sonar.qualitygate.wait=false diff --git a/src/assets/icons/chevron-down.svg b/src/assets/icons/chevron-down.svg new file mode 100644 index 0000000..9ff74b4 --- /dev/null +++ b/src/assets/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/chevron-up.svg b/src/assets/icons/chevron-up.svg new file mode 100644 index 0000000..a5e6149 --- /dev/null +++ b/src/assets/icons/chevron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/color-picker.svg b/src/assets/icons/color-picker.svg new file mode 100644 index 0000000..7c1574d --- /dev/null +++ b/src/assets/icons/color-picker.svg @@ -0,0 +1,15 @@ + + + + + + diff --git a/src/assets/icons/exit-fullscreen.svg b/src/assets/icons/exit-fullscreen.svg new file mode 100644 index 0000000..48b88b1 --- /dev/null +++ b/src/assets/icons/exit-fullscreen.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/src/assets/icons/filter-list.svg b/src/assets/icons/filter-list.svg new file mode 100644 index 0000000..914e2ad --- /dev/null +++ b/src/assets/icons/filter-list.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/fullscreen.svg b/src/assets/icons/fullscreen.svg new file mode 100644 index 0000000..ea10452 --- /dev/null +++ b/src/assets/icons/fullscreen.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/src/assets/icons/help-outline-active.svg b/src/assets/icons/help-outline-active.svg new file mode 100644 index 0000000..c0271f7 --- /dev/null +++ b/src/assets/icons/help-outline-active.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/src/assets/icons/resize.svg b/src/assets/icons/resize.svg new file mode 100644 index 0000000..d0bd1e4 --- /dev/null +++ b/src/assets/icons/resize.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/icons/thumb-down.svg b/src/assets/icons/thumb-down.svg new file mode 100644 index 0000000..30dc2d8 --- /dev/null +++ b/src/assets/icons/thumb-down.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/src/assets/icons/thumb-up.svg b/src/assets/icons/thumb-up.svg new file mode 100644 index 0000000..bcbc3ae --- /dev/null +++ b/src/assets/icons/thumb-up.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/src/assets/icons/visibility-off.svg b/src/assets/icons/visibility-off.svg new file mode 100644 index 0000000..8b04549 --- /dev/null +++ b/src/assets/icons/visibility-off.svg @@ -0,0 +1,5 @@ + + + + diff --git a/src/assets/icons/visibility.svg b/src/assets/icons/visibility.svg new file mode 100644 index 0000000..b0b727e --- /dev/null +++ b/src/assets/icons/visibility.svg @@ -0,0 +1,5 @@ + + + + diff --git a/src/assets/styles/_common-variables.scss b/src/assets/styles/_common-variables.scss index f887b94..0635504 100644 --- a/src/assets/styles/_common-variables.scss +++ b/src/assets/styles/_common-variables.scss @@ -62,6 +62,9 @@ body { --iqser-font-family: Inter, sans-serif; --iqser-app-name-font-family: Inter, sans-serif; --iqser-circle-button-radius: 50%; + --iqser-side-nav-item-radius: 20px; + --iqser-dot-overlay-background: rgba(var(--iqser-primary-rgb), 0.1); + --iqser-chevron-button-bg: transparent; } $required-variables: 'iqser-primary'; diff --git a/src/assets/styles/common-base-screen.scss b/src/assets/styles/common-base-screen.scss index 474d1a1..874f031 100644 --- a/src/assets/styles/common-base-screen.scss +++ b/src/assets/styles/common-base-screen.scss @@ -32,6 +32,7 @@ .buttons { display: flex; margin-right: 8px; + align-items: center; > *:not(:last-child) { margin-right: 14px; @@ -49,22 +50,18 @@ font-family: var(--iqser-app-name-font-family); font-size: var(--iqser-app-name-font-size); color: var(--iqser-app-name-color); - font-weight: 800; + font-weight: normal; white-space: nowrap; } .dev-mode { background-color: var(--iqser-primary); color: var(--iqser-white); - font-size: 22px; - line-height: 16px; - text-align: center; position: fixed; - top: 0; - z-index: 100; right: 0; height: var(--iqser-top-bar-height); - word-break: break-all; + writing-mode: vertical-rl; + text-orientation: upright; display: flex; justify-content: center; align-items: center; diff --git a/src/assets/styles/common-buttons.scss b/src/assets/styles/common-buttons.scss index d6360fd..f2279b0 100644 --- a/src/assets/styles/common-buttons.scss +++ b/src/assets/styles/common-buttons.scss @@ -37,7 +37,7 @@ } .overlay { - background: rgba(var(--iqser-primary-rgb), 0.1); + background: var(--iqser-dot-overlay-background); } } @@ -66,6 +66,14 @@ iqser-icon-button { --mdc-text-button-label-text-color: var(--iqser-text); padding: 0 14px; + width: 100%; + + &:hover:not([disabled]) { + .mat-mdc-button-persistent-ripple::before { + background-color: #000; + opacity: 0.04; + } + } &[disabled] { --mdc-text-button-disabled-label-text-color: rgba(var(--iqser-text-rgb), 0.3); @@ -169,7 +177,6 @@ iqser-circle-button { iqser-chevron-button { @include buttonShape; @include ariaExpanded; - @include dotOverlay; @include labelNoWrap; display: block; @@ -177,10 +184,14 @@ iqser-chevron-button { .mat-mdc-button { @include iconSize14; + background-color: var(--iqser-chevron-button-bg); + &:not([disabled]) { --mdc-text-button-label-text-color: var(--iqser-text); } } + + @include dotOverlay; } iqser-user-button { diff --git a/src/assets/styles/common-checkbox.scss b/src/assets/styles/common-checkbox.scss index 5c45ae0..96cadac 100644 --- a/src/assets/styles/common-checkbox.scss +++ b/src/assets/styles/common-checkbox.scss @@ -5,6 +5,7 @@ $ripple-size: 26px; flex: 0 0 $checkbox-size; width: $checkbox-size; height: $checkbox-size; + margin-top: 4px; } .mat-mdc-checkbox, @@ -24,14 +25,14 @@ $ripple-size: 26px; --mdc-checkbox-selected-hover-state-layer-color: var(--iqser-primary); --mdc-checkbox-selected-pressed-state-layer-color: var(--iqser-primary); - input[type='checkbox'] { - margin-top: calc($checkbox-size * -1.5); - } + .mdc-form-field { + align-items: start; - .mdc-form-field > label { - padding-left: 8px; - line-height: 24px; - white-space: nowrap; + & > label { + padding-left: 8px; + line-height: 24px; + white-space: normal; + } } .mdc-checkbox__ripple { @@ -54,7 +55,7 @@ $ripple-size: 26px; .mat-mdc-checkbox-touch-target { height: $ripple-size; width: $ripple-size; - transform: translate(calc(($checkbox-size - $ripple-size) / 2), calc(($checkbox-size - $ripple-size) / 2)); + transform: translate(-50%, -50%); } } diff --git a/src/assets/styles/common-chips.scss b/src/assets/styles/common-chips.scss index d0a3079..d6fcf12 100644 --- a/src/assets/styles/common-chips.scss +++ b/src/assets/styles/common-chips.scss @@ -39,6 +39,10 @@ mat-chip-listbox { &.mat-mdc-chip-selected { background-color: var(--iqser-btn-bg); + .mdc-evolution-chip__text-label { + color: #212121; + } + .selected-mark { display: block; } diff --git a/src/assets/styles/common-components.scss b/src/assets/styles/common-components.scss index b3907a6..dbfc204 100644 --- a/src/assets/styles/common-components.scss +++ b/src/assets/styles/common-components.scss @@ -18,9 +18,18 @@ &.large { height: 32px; width: 32px; + min-width: 32px; font-size: var(--iqser-font-size); } + &.extra-small { + height: 16px; + width: 16px; + min-width: 16px; + font-size: 10px; + font-weight: lighter; + } + &.gray-dark { background-color: var(--iqser-user-avatar-1); color: var(--iqser-text); diff --git a/src/assets/styles/common-dialogs.scss b/src/assets/styles/common-dialogs.scss index 65e1490..9653456 100644 --- a/src/assets/styles/common-dialogs.scss +++ b/src/assets/styles/common-dialogs.scss @@ -15,6 +15,10 @@ } } +.use-backslash-n-as-line-break { + white-space: pre-line !important; +} + .dialog { position: relative; min-height: 80px; @@ -43,7 +47,10 @@ font-weight: bold; padding-bottom: 8px; } + } + &.redaction, + &.force-annotation { iqser-details-radio { padding-top: 20px; } @@ -68,3 +75,7 @@ margin-left: auto; } } + +.large-form-dialog .dialog > form { + display: contents; +} diff --git a/src/assets/styles/common-file-upload.scss b/src/assets/styles/common-file-upload.scss new file mode 100644 index 0000000..55da707 --- /dev/null +++ b/src/assets/styles/common-file-upload.scss @@ -0,0 +1,59 @@ +.iqser-upload-file { + .upload-area, + .file-area { + display: flex; + align-items: center; + border-radius: 8px; + width: 100%; + box-sizing: border-box; + background: var(--iqser-alt-background); + height: 68px; + + &.drag-over { + background-color: var(--iqser-file-drop-drag-over); + } + } + + .upload-area { + gap: 16px; + cursor: pointer; + padding: 0 32px; + + mat-icon, + div { + opacity: 0.5; + transition: 0.1s; + } + + div { + font-size: 16px; + font-weight: 500; + } + } + + .file-area { + gap: 10px; + + mat-icon:first-child { + opacity: 0.5; + margin-left: 16px; + } + + mat-icon:last-child { + margin-left: auto; + margin-right: 16px; + cursor: pointer; + } + + mat-icon { + transform: scale(0.7); + } + + p { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + max-width: 490px; + } + } +} diff --git a/src/assets/styles/common-full-pages.scss b/src/assets/styles/common-full-pages.scss index b83f25a..9c081b4 100644 --- a/src/assets/styles/common-full-pages.scss +++ b/src/assets/styles/common-full-pages.scss @@ -7,11 +7,11 @@ .full-page-section { opacity: 0.7; background: var(--iqser-background); - z-index: 900; + z-index: 1001; } .full-page-content { - z-index: 1000; + z-index: 1002; justify-content: center; align-items: center; flex-direction: column; diff --git a/src/assets/styles/common-help-mode.scss b/src/assets/styles/common-help-mode.scss index 95b824f..211e3b2 100644 --- a/src/assets/styles/common-help-mode.scss +++ b/src/assets/styles/common-help-mode.scss @@ -9,4 +9,9 @@ background: rgba(92, 229, 148, 0.5); box-shadow: 0 0 0 2px var(--iqser-helpmode-primary) inset; cursor: help; + + &.documine-theme { + background: rgba(253, 189, 0, 0.5); + box-shadow: 0 0 0 2px var(--iqser-yellow-2) inset; + } } diff --git a/src/assets/styles/common-inputs.scss b/src/assets/styles/common-inputs.scss index 25b4f44..2b5e2f7 100644 --- a/src/assets/styles/common-inputs.scss +++ b/src/assets/styles/common-inputs.scss @@ -94,29 +94,20 @@ iqser-dynamic-input { input:not([type='checkbox']), textarea { box-sizing: border-box; - margin-top: 3px; min-height: var(--iqser-inputs-height); line-height: 32px; padding-left: calc((var(--iqser-inputs-height) - 14px) / 2); padding-right: calc((var(--iqser-inputs-height) - 14px) / 2); } - .mat-mdc-form-field { - margin-top: 3px; - - input { - margin-top: 0; - } - } - .mat-mdc-form-field-subscript-wrapper { display: none; } .mdc-text-field--outlined { --mdc-outlined-text-field-focus-outline-width: 1px; - --mdc-shape-small: 8px; // border-radius - --mdc-outlined-text-field-container-shape: 8px; // border-radius + --mdc-shape-small: 8px; + --mdc-outlined-text-field-container-shape: 8px; border-bottom-left-radius: var(--mdc-shape-small); border-bottom-right-radius: var(--mdc-shape-small); } @@ -220,7 +211,7 @@ iqser-dynamic-input { font-size: 11px; letter-spacing: 0; line-height: 14px; - margin-bottom: 2px; + margin-bottom: 5px; color: var(--iqser-text); &.mat-checkbox-layout { @@ -231,7 +222,7 @@ iqser-dynamic-input { &.required label:after { content: ' *'; - color: var(--iqser-primary); + color: var(--iqser-red-1); } &.datepicker-wrapper { @@ -248,7 +239,7 @@ iqser-dynamic-input { .mat-datepicker-toggle { position: absolute; right: 0; - bottom: 1px; + bottom: -4px; color: var(--iqser-accent); &.mat-datepicker-toggle-active { @@ -265,6 +256,18 @@ iqser-dynamic-input { width: 14px; } + button.cdk-focused, + button:hover { + span { + &::before { + width: 32px; + height: 32px; + top: 10px; + left: 8px; + } + } + } + .mat-mdc-icon-button svg { width: unset; height: unset; diff --git a/src/assets/styles/common-layout.scss b/src/assets/styles/common-layout.scss index 1d981f9..66bf4c3 100644 --- a/src/assets/styles/common-layout.scss +++ b/src/assets/styles/common-layout.scss @@ -86,17 +86,13 @@ section.settings { } .fullscreen { - .page-header { - position: absolute; - top: 0; - } - .content-inner { height: calc(100% - 50px); } - .overlay-shadow { - top: 50px; + .right-container { + transform: translateY(61px); + height: calc(100% - 61px); } } @@ -160,13 +156,15 @@ section.settings { box-sizing: border-box; background: var(--iqser-background); overflow: hidden; - transition: - width ease-in-out 0.2s, - min-width ease-in-out 0.2s; + &.with-transition { + transition: + width ease-in-out 0.2s, + min-width ease-in-out 0.2s; + } + @include common-mixins.scroll-bar; &:hover { overflow-y: auto; - @include common-mixins.scroll-bar; } .collapsed-wrapper { @@ -258,6 +256,10 @@ section.settings { cursor: pointer; } +.cursor-default { + cursor: default; +} + .fit-content { width: fit-content; } diff --git a/src/assets/styles/common-menu.scss b/src/assets/styles/common-menu.scss index 69b8823..43458fe 100644 --- a/src/assets/styles/common-menu.scss +++ b/src/assets/styles/common-menu.scss @@ -25,7 +25,7 @@ .mat-mdc-menu-item { font-size: var(--iqser-font-size); color: var(--iqser-text); - padding: 0 26px 0 8px; + padding: 0 26px 0 8px !important; margin: var(--iqser-menu-item-margin); border-radius: 4px; width: -webkit-fill-available; @@ -58,7 +58,7 @@ } &.padding-left { - padding-left: 56px; + padding-left: 56px !important; } &:last-of-type { diff --git a/src/assets/styles/common-select.scss b/src/assets/styles/common-select.scss index c82a587..6689547 100644 --- a/src/assets/styles/common-select.scss +++ b/src/assets/styles/common-select.scss @@ -1,9 +1,10 @@ @use 'common-mixins'; .mat-mdc-select { - padding: 0 11px; + padding: 0 calc(var(--iqser-inputs-height) - 25px); box-sizing: border-box; - --mat-select-trigger-text-line-height: 36px; + --mat-select-trigger-text-line-height: var(--iqser-inputs-height); + --mat-select-trigger-text-size: var(--iqser-inputs-font-size); } .mat-mdc-select-panel { @@ -12,6 +13,7 @@ background-color: var(--iqser-background); @include common-mixins.scroll-bar; @include common-mixins.drop-shadow; + --mat-option-selected-state-label-text-color: var(--iqser-primary); } .mat-mdc-select-arrow-wrapper { @@ -46,6 +48,12 @@ color: var(--iqser-text); } -.mat-mdc-option .mat-mdc-option-pseudo-checkbox { +.mat-form-field-disabled { + .mat-mdc-select-value { + color: var(--iqser-grey-3); + } +} + +.mat-mdc-option:not(.mat-mdc-option-multiple) .mat-mdc-option-pseudo-checkbox { display: none; } diff --git a/src/assets/styles/common-side-nav.scss b/src/assets/styles/common-side-nav.scss index 2fde61d..9e40736 100644 --- a/src/assets/styles/common-side-nav.scss +++ b/src/assets/styles/common-side-nav.scss @@ -19,7 +19,7 @@ iqser-side-nav { .item { margin-bottom: 4px; - border-radius: 20px; + border-radius: var(--iqser-side-nav-item-radius); padding: 9px 16px; cursor: pointer; transition: background-color 0.2s; diff --git a/src/assets/styles/common-styles.scss b/src/assets/styles/common-styles.scss index 4e98daa..4d8e4af 100644 --- a/src/assets/styles/common-styles.scss +++ b/src/assets/styles/common-styles.scss @@ -1,3 +1,5 @@ +@use 'ngx-toastr/toastr'; + @use 'common-utilities'; @use 'common-inputs'; @use 'common-buttons'; @@ -28,6 +30,7 @@ @use 'common-toggle-button'; @use 'common-tooltips'; @use 'common-file-drop'; +@use 'common-file-upload'; @use 'common-side-nav'; @use 'common-color-picker'; @use 'common-skeleton'; diff --git a/src/assets/styles/common-tables.scss b/src/assets/styles/common-tables.scss index d50da06..2f56971 100644 --- a/src/assets/styles/common-tables.scss +++ b/src/assets/styles/common-tables.scss @@ -8,10 +8,7 @@ flex: 1; align-items: center; justify-content: flex-end; - - > *:not(:last-child) { - margin-right: 10px; - } + gap: 10px; } .header-item { @@ -23,6 +20,7 @@ border-bottom: 1px solid var(--iqser-separator); box-sizing: border-box; padding: 0 24px; + gap: 10px; .header-title { display: flex; @@ -34,17 +32,10 @@ padding: 0 24px 0 10px; } - > *:not(:last-child) { - margin-right: 10px; - } - .actions { display: flex; align-items: center; justify-content: flex-end; - - > *:not(:last-child) { - margin-right: 16px; - } + gap: 16px; } } diff --git a/src/assets/styles/common-texts.scss b/src/assets/styles/common-texts.scss index 0e9194e..9959674 100644 --- a/src/assets/styles/common-texts.scss +++ b/src/assets/styles/common-texts.scss @@ -149,6 +149,10 @@ pre { @include mixins.line-clamp(4); } +.clamp-5 { + @include mixins.line-clamp(5); +} + .no-wrap { white-space: nowrap; } diff --git a/src/assets/styles/common-toasts.scss b/src/assets/styles/common-toasts.scss index 5517639..cfeb9ca 100644 --- a/src/assets/styles/common-toasts.scss +++ b/src/assets/styles/common-toasts.scss @@ -84,11 +84,11 @@ $toast-width: 400px; } .toast-success { - background-color: var(--iqser-green-2); + background-color: #5ce594; } .toast-error { - background-color: var(--iqser-red-1); + background-color: #dd4d50; color: var(--iqser-white); } diff --git a/src/assets/styles/common-toggle.scss b/src/assets/styles/common-toggle.scss index f463999..f0292ca 100644 --- a/src/assets/styles/common-toggle.scss +++ b/src/assets/styles/common-toggle.scss @@ -1,6 +1,7 @@ .mat-mdc-slide-toggle { .mdc-switch { --mdc-switch-handle-elevation: none; + --mdc-switch-handle-elevation-shadow: none; --mdc-switch-selected-track-color: var(--iqser-primary); --mdc-switch-selected-hover-track-color: var(--iqser-primary); @@ -31,9 +32,19 @@ --mdc-switch-track-width: 30px; --mdc-switch-track-height: 16px; --mdc-switch-track-shape: 8px; - --mdc-switch-handle-width: 12px; - --mdc-switch-handle-height: 12px; + --mat-switch-with-icon-handle-size: 12px; --mdc-switch-handle-shape: 6px; + + --mat-switch-unselected-with-icon-handle-horizontal-margin: 0 2px; + --mat-switch-unselected-pressed-handle-horizontal-margin: 0 2px; + + --mat-switch-selected-with-icon-handle-horizontal-margin: 0 6px; + --mat-switch-selected-pressed-handle-horizontal-margin: 0 6px; + --mat-switch-selected-handle-horizontal-margin: 0 6px; + + --mat-switch-unselected-handle-size: 12px; + --mat-switch-selected-handle-size: 12px; + --mat-switch-pressed-handle-size: 12px; } .mdc-form-field > label { @@ -41,17 +52,6 @@ padding-left: 0; } - .mdc-switch__handle { - right: 8px; - left: unset; - } - - .mdc-switch--unselected { - .mdc-switch__handle { - right: 4px; - } - } - .mdc-switch__icons, .mdc-switch__ripple { display: none; diff --git a/src/assets/styles/common-utilities.scss b/src/assets/styles/common-utilities.scss index 955c96b..3aad5b8 100644 --- a/src/assets/styles/common-utilities.scss +++ b/src/assets/styles/common-utilities.scss @@ -1,3 +1,5 @@ +@use 'sass:string'; +@use 'sass:list'; /* Margins, paddings */ $start: 0; @@ -7,19 +9,19 @@ $values: ''; $sides: (top, bottom, left, right); @for $i from $start + 1 through $end { - $values: append($values, $i, comma); - $values: set-nth($values, 1, $start); + $values: list.append($values, $i, comma); + $values: list.set-nth($values, 1, $start); } // TODO: Check if !important can be avoided @each $space in $values { @each $side in $sides { - .m#{str-slice($side, 0, 1)}-#{$space} { + .m#{string.slice($side, 0, 1)}-#{$space} { margin-#{$side}: #{$space}px !important; } - .p#{str-slice($side, 0, 1)}-#{$space} { + .p#{string.slice($side, 0, 1)}-#{$space} { padding-#{$side}: #{$space}px !important; } } diff --git a/src/index.ts b/src/index.ts index d6bd5ca..e765e97 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,13 +3,11 @@ export * from './lib/dialog'; export * from './lib/form'; export * from './lib/listing'; export * from './lib/help-mode'; -export * from './lib/inputs'; export * from './lib/services'; export * from './lib/loading'; export * from './lib/error'; export * from './lib/search'; export * from './lib/upload-file'; -export * from './lib/empty-state'; export * from './lib/caching'; export * from './lib/translations'; export * from './lib/pipes'; diff --git a/src/lib/buttons/chevron-button/chevron-button.component.html b/src/lib/buttons/chevron-button/chevron-button.component.html index 84abab7..6993a73 100644 --- a/src/lib/buttons/chevron-button/chevron-button.component.html +++ b/src/lib/buttons/chevron-button/chevron-button.component.html @@ -1,6 +1,8 @@ - -
+@if (showDot()) { +
+} diff --git a/src/lib/buttons/chevron-button/chevron-button.component.scss b/src/lib/buttons/chevron-button/chevron-button.component.scss index d92b062..1271f66 100644 --- a/src/lib/buttons/chevron-button/chevron-button.component.scss +++ b/src/lib/buttons/chevron-button/chevron-button.component.scss @@ -1,5 +1,5 @@ button { - padding: 0 10px 0 14px; + --mat-text-button-with-icon-horizontal-padding: 10px 0 14px; mat-icon { width: 14px; diff --git a/src/lib/buttons/chevron-button/chevron-button.component.ts b/src/lib/buttons/chevron-button/chevron-button.component.ts index 1014673..96a38dc 100644 --- a/src/lib/buttons/chevron-button/chevron-button.component.ts +++ b/src/lib/buttons/chevron-button/chevron-button.component.ts @@ -1,21 +1,19 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; -import { randomString } from '../../utils'; -import { NgIf } from '@angular/common'; +import { booleanAttribute, ChangeDetectionStrategy, Component, input } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; +import { randomString } from '../../utils'; @Component({ selector: 'iqser-chevron-button', templateUrl: './chevron-button.component.html', styleUrls: ['./chevron-button.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [NgIf, MatIconModule, MatButtonModule], + imports: [MatIconModule, MatButtonModule], }) export class ChevronButtonComponent { - @Input({ required: true }) label!: string; - @Input() showDot = false; - @Input() primary = false; - @Input() disabled = false; - @Input() buttonId = `${randomString()}-chevron-button`; + readonly label = input.required(); + readonly showDot = input(false, { transform: booleanAttribute }); + readonly primary = input(false, { transform: booleanAttribute }); + readonly disabled = input(false, { transform: booleanAttribute }); + readonly buttonId = input(`${randomString()}-chevron-button`); } diff --git a/src/lib/buttons/chevron-button/index.ts b/src/lib/buttons/chevron-button/index.ts new file mode 100644 index 0000000..fe12359 --- /dev/null +++ b/src/lib/buttons/chevron-button/index.ts @@ -0,0 +1 @@ +export * from './chevron-button.component'; diff --git a/src/lib/buttons/circle-button/circle-button.component.html b/src/lib/buttons/circle-button/circle-button.component.html index 5969c74..61b3d2a 100644 --- a/src/lib/buttons/circle-button/circle-button.component.html +++ b/src/lib/buttons/circle-button/circle-button.component.html @@ -1,20 +1,25 @@ -
+
-
+ @if (showDot()) { +
+ } + + @if (dropdownButton()) { +
+ }
diff --git a/src/lib/buttons/circle-button/circle-button.component.scss b/src/lib/buttons/circle-button/circle-button.component.scss index 0320fef..66ab031 100644 --- a/src/lib/buttons/circle-button/circle-button.component.scss +++ b/src/lib/buttons/circle-button/circle-button.component.scss @@ -1,4 +1,16 @@ :host > div { width: var(--circle-button-size); height: var(--circle-button-size); + + .arrow-down { + border: 5px solid transparent; + border-top-color: black; + position: fixed; + margin-left: 12px; + margin-top: -8px; + + &.disabled { + border-top-color: var(--iqser-grey-3); + } + } } diff --git a/src/lib/buttons/circle-button/circle-button.component.ts b/src/lib/buttons/circle-button/circle-button.component.ts index 840379a..a7c3532 100644 --- a/src/lib/buttons/circle-button/circle-button.component.ts +++ b/src/lib/buttons/circle-button/circle-button.component.ts @@ -1,5 +1,16 @@ -import { NgIf } from '@angular/common'; -import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, inject, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { + booleanAttribute, + ChangeDetectionStrategy, + Component, + effect, + ElementRef, + EventEmitter, + inject, + input, + numberAttribute, + Output, + viewChild, +} from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltip, MatTooltipModule } from '@angular/material/tooltip'; @@ -13,38 +24,39 @@ import { CircleButtonType, CircleButtonTypes } from '../types/circle-button.type templateUrl: './circle-button.component.html', styleUrls: ['./circle-button.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [MatTooltipModule, MatIconModule, NgIf, MatButtonModule, StopPropagationDirective], + imports: [MatTooltipModule, MatIconModule, MatButtonModule, StopPropagationDirective], }) -export class CircleButtonComponent implements OnInit { +export class CircleButtonComponent { readonly #elementRef = inject(ElementRef); - @ViewChild(MatTooltip) private readonly _matTooltip!: MatTooltip; + protected readonly _matTooltip = viewChild.required(MatTooltip); protected readonly _circleButtonTypes = CircleButtonTypes; protected readonly _hasRouterLink = !!inject(RouterLink, { optional: true, host: true }); - @Input() buttonId = `${randomString()}-circle-button`; - @Input({ required: true }) icon!: string; - @Input() tooltip?: string; - @Input() tooltipClass?: string; - @Input() showDot = false; - @Input() tooltipPosition: IqserTooltipPosition = IqserTooltipPositions.above; - @Input() disabled = false; - @Input() type: CircleButtonType = CircleButtonTypes.default; - @Input() greySelected = false; - @Input() helpModeButton = false; - @Input() removeTooltip = false; - @Input() isSubmit = false; - @Input() size = 34; - @Input() iconSize = 14; + readonly buttonId = input(`${randomString()}-circle-button`); + readonly icon = input.required(); + readonly tooltip = input(''); + readonly tooltipClass = input(''); + readonly showDot = input(false, { transform: booleanAttribute }); + readonly tooltipPosition = input(IqserTooltipPositions.above); + readonly disabled = input(false, { transform: booleanAttribute }); + readonly type = input(CircleButtonTypes.default); + readonly greySelected = input(false, { transform: booleanAttribute }); + readonly removeTooltip = input(false, { transform: booleanAttribute }); + readonly isSubmit = input(false, { transform: booleanAttribute }); + readonly dropdownButton = input(false, { transform: booleanAttribute }); + readonly size = input(34, { transform: numberAttribute }); + readonly iconSize = input(14, { transform: numberAttribute }); @Output() readonly action = new EventEmitter(); - ngOnInit() { - this.#elementRef.nativeElement.style.setProperty('--circle-button-size', `${this.size}px`); - this.#elementRef.nativeElement.style.setProperty('--circle-button-icon-size', `${this.iconSize}px`); + constructor() { + effect(() => { + this.#elementRef.nativeElement.style.setProperty('--circle-button-size', `${this.size()}px`); + this.#elementRef.nativeElement.style.setProperty('--circle-button-icon-size', `${this.iconSize()}px`); + }); } performAction($event: MouseEvent) { - if (this.removeTooltip) { - this._matTooltip.hide(); + if (this.removeTooltip()) { + this._matTooltip().hide(); // Timeout to allow tooltip to disappear first, // useful when removing an item from the list without a confirmation dialog setTimeout(() => this.action.emit($event)); diff --git a/src/lib/buttons/icon-button/icon-button.component.html b/src/lib/buttons/icon-button/icon-button.component.html index d9d4967..d086836 100644 --- a/src/lib/buttons/icon-button/icon-button.component.html +++ b/src/lib/buttons/icon-button/icon-button.component.html @@ -1,14 +1,19 @@ -
+@if (showDot()) { +
+} diff --git a/src/lib/buttons/icon-button/icon-button.component.ts b/src/lib/buttons/icon-button/icon-button.component.ts index 6f2c018..159a512 100644 --- a/src/lib/buttons/icon-button/icon-button.component.ts +++ b/src/lib/buttons/icon-button/icon-button.component.ts @@ -1,37 +1,42 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, inject, Input, Output } from '@angular/core'; -import { IconButtonType, IconButtonTypes } from '../types/icon-button.type'; -import { randomString } from '../../utils'; -import { NgClass, NgIf } from '@angular/common'; +import { NgClass } from '@angular/common'; +import { booleanAttribute, ChangeDetectionStrategy, Component, computed, EventEmitter, inject, input, Output } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; -import { StopPropagationDirective } from '../../directives'; import { RouterLink } from '@angular/router'; +import { StopPropagationDirective } from '../../directives'; +import { randomString } from '../../utils'; +import { IconButtonType, IconButtonTypes } from '../types/icon-button.type'; @Component({ selector: 'iqser-icon-button', templateUrl: './icon-button.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [NgClass, MatButtonModule, NgIf, MatIconModule, StopPropagationDirective], + imports: [NgClass, MatButtonModule, MatIconModule, StopPropagationDirective], }) export class IconButtonComponent { - @Input({ required: true }) label!: string; - @Input() buttonId = `${randomString()}-icon-button`; - @Input() icon?: string; - @Input() showDot = false; - @Input() active = false; - @Input() disabled = false; - @Input() submit = false; - @Input() type: IconButtonType = IconButtonTypes.default; - @Output() readonly action = new EventEmitter(); protected readonly _hasRouterLink = !!inject(RouterLink, { optional: true, host: true }); - - get classes(): Record { + readonly label = input.required(); + readonly buttonId = input(`${randomString()}-icon-button`); + readonly icon = input(); + readonly showDot = input(false, { transform: booleanAttribute }); + readonly active = input(false, { transform: booleanAttribute }); + readonly disabled = input(false, { transform: booleanAttribute }); + readonly submit = input(false, { transform: booleanAttribute }); + readonly type = input(IconButtonTypes.default); + protected readonly _classes = computed(() => { return { - overlay: this.showDot, - [this.type]: true, - 'has-icon': !!this.icon, - active: this.active, + overlay: this.showDot(), + [this.type()]: true, + 'has-icon': !!this.icon(), + active: this.active(), }; + }); + @Output() readonly action = new EventEmitter(); + + emitAction($event: MouseEvent) { + const activeElement = document.activeElement as HTMLElement; + if (activeElement.tagName?.toLowerCase() === 'button') { + this.action.emit($event); + } } } diff --git a/src/lib/buttons/index.ts b/src/lib/buttons/index.ts index 30ba65f..8476a99 100644 --- a/src/lib/buttons/index.ts +++ b/src/lib/buttons/index.ts @@ -3,4 +3,3 @@ export * from './types/circle-button.type'; export * from './icon-button/icon-button.component'; export * from './circle-button/circle-button.component'; -export * from './chevron-button/chevron-button.component'; diff --git a/src/lib/buttons/types/circle-button.type.ts b/src/lib/buttons/types/circle-button.type.ts index dd4494c..349c720 100644 --- a/src/lib/buttons/types/circle-button.type.ts +++ b/src/lib/buttons/types/circle-button.type.ts @@ -3,7 +3,6 @@ export const CircleButtonTypes = { primary: 'primary', warn: 'warn', dark: 'dark', - help: 'help', } as const; export type CircleButtonType = keyof typeof CircleButtonTypes; diff --git a/src/lib/caching/dynamic-cache.ts b/src/lib/caching/dynamic-cache.ts index d9cc4c4..a3d61e2 100644 --- a/src/lib/caching/dynamic-cache.ts +++ b/src/lib/caching/dynamic-cache.ts @@ -1,4 +1,4 @@ -import { List } from '../utils'; +import { List } from '../utils/types/iqser-types'; export interface DynamicCache { readonly urls: List; diff --git a/src/lib/dialog/base-dialog.component.ts b/src/lib/dialog/base-dialog.component.ts index b17e202..6849431 100644 --- a/src/lib/dialog/base-dialog.component.ts +++ b/src/lib/dialog/base-dialog.component.ts @@ -1,34 +1,37 @@ -import { AfterViewInit, Directive, HostListener, inject, OnDestroy } from '@angular/core'; -import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { AfterViewInit, Directive, HostListener, inject, OnDestroy, signal } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; -import { hasFormChanged, IqserEventTarget } from '../utils'; -import { ConfirmOptions } from '.'; -import { ConfirmationDialogService } from './confirmation-dialog.service'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { debounceTime, firstValueFrom, fromEvent, merge, of, Subscription } from 'rxjs'; -import { LoadingService } from '../loading'; -import { Toaster } from '../services'; -import { IconButtonTypes } from '../buttons'; import { tap } from 'rxjs/operators'; +import { IconButtonTypes } from '../buttons/types/icon-button.type'; +import { LoadingService } from '../loading/loading.service'; +import { Toaster } from '../services/toaster.service'; +import { hasFormChanged } from '../utils/functions'; +import { IqserEventTarget } from '../utils/types/events.type'; +import { ConfirmationDialogService } from './confirmation-dialog.service'; +import { ConfirmOptions } from './confirmation-dialog/confirmation-dialog.component'; -const TARGET_NODE = 'mat-dialog-container'; +const DIALOG_CONTAINER = 'mat-dialog-container'; +const TEXT_INPUT = 'text'; export interface SaveOptions { closeAfterSave?: boolean; + nextAction?: boolean; addMembers?: boolean; } @Directive() export abstract class BaseDialogComponent implements AfterViewInit, OnDestroy { - readonly iconButtonTypes = IconButtonTypes; - form?: UntypedFormGroup; - initialFormValue!: Record; + readonly #confirmationDialogService = inject(ConfirmationDialogService); + readonly #dialog = inject(MatDialog); + protected readonly _hasErrors = signal(true); protected readonly _formBuilder = inject(UntypedFormBuilder); protected readonly _loadingService = inject(LoadingService); protected readonly _toaster = inject(Toaster); - protected readonly _subscriptions: Subscription = new Subscription(); - readonly #confirmationDialogService = inject(ConfirmationDialogService); - readonly #dialog = inject(MatDialog); - #hasErrors = false; + protected readonly _subscriptions = new Subscription(); + readonly iconButtonTypes = IconButtonTypes; + form?: UntypedFormGroup; + initialFormValue!: Record; protected constructor( protected readonly _dialogRef: MatDialogRef, @@ -44,16 +47,19 @@ export abstract class BaseDialogComponent implements AfterViewInit, OnDestroy { } get disabled(): boolean { - return !this.valid || !this.changed || this.#hasErrors; + return !this.valid || !this.changed || this._hasErrors(); } ngAfterViewInit() { this._subscriptions.add(this._dialogRef.backdropClick().subscribe(() => this.close())); const valueChanges = this.form?.valueChanges ?? of(null); const events = [fromEvent(window, 'keyup'), fromEvent(window, 'input'), valueChanges]; + this._hasErrors.set(!!document.getElementsByClassName('ng-invalid')[0]); const events$ = merge(...events).pipe( debounceTime(10), - tap(() => (this.#hasErrors = !!document.getElementsByClassName('ng-invalid')[0])), + tap(() => { + this._hasErrors.set(!!document.getElementsByClassName('ng-invalid')[0]); + }), ); this._subscriptions.add(events$.subscribe()); } @@ -82,9 +88,18 @@ export abstract class BaseDialogComponent implements AfterViewInit, OnDestroy { @HostListener('window:keydown.Enter', ['$event']) onEnter(event: KeyboardEvent): void { - event?.stopImmediatePropagation(); - const node = (event.target as IqserEventTarget).localName?.trim()?.toLowerCase(); - if (this.valid && !this.disabled && this.changed && node === TARGET_NODE) { + const target = event.target as IqserEventTarget; + const isDialogSelected = target.localName?.trim()?.toLowerCase() === DIALOG_CONTAINER; + const isTextInputSelected = target.type?.trim()?.toLowerCase() === TEXT_INPUT; + + if ( + this.valid && + !this.disabled && + (this.changed || !this._isInEditMode) && + this.#dialog.openDialogs.length === 1 && + (isDialogSelected || isTextInputSelected) + ) { + event?.stopImmediatePropagation(); this.save(); } } diff --git a/src/lib/dialog/confirmation-dialog.service.ts b/src/lib/dialog/confirmation-dialog.service.ts index dfba015..e7b3b2f 100644 --- a/src/lib/dialog/confirmation-dialog.service.ts +++ b/src/lib/dialog/confirmation-dialog.service.ts @@ -1,7 +1,13 @@ import { inject, Injectable } from '@angular/core'; -import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; -import { ConfirmationDialogComponent, ConfirmOption, defaultDialogConfig, IConfirmationDialogData, TitleColors } from '.'; import { MatDialog } from '@angular/material/dialog'; +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; +import { + ConfirmationDialogComponent, + ConfirmOption, + IConfirmationDialogData, + TitleColors, +} from './confirmation-dialog/confirmation-dialog.component'; +import { defaultDialogConfig } from './dialog.service'; @Injectable({ providedIn: 'root', diff --git a/src/lib/dialog/confirmation-dialog/confirmation-dialog.component.html b/src/lib/dialog/confirmation-dialog/confirmation-dialog.component.html index 015944e..c15018f 100644 --- a/src/lib/dialog/confirmation-dialog/confirmation-dialog.component.html +++ b/src/lib/dialog/confirmation-dialog/confirmation-dialog.component.html @@ -1,56 +1,83 @@
-
-
- - - -
+ @if (showToast && config.toastMessage) { +
+
+ + + +
+ }
+ @if (config.component) { + + } +

-

+ @if (config.details) { +

+ } -
- - -
+ @if (config.requireInput) { +
+ + +
+ } -
- - - {{ checkbox.label | translate: config.translateParams }} - - - -
+ @if (config.checkboxes.length > 0) { +
+ @for (checkbox of config.checkboxes; track checkbox) { + + {{ checkbox.label | translate: config.translateParams }} + + + } +
+ }
-
- +
+ @if (!config.cancelButtonPrimary) { + + } @else { +
+ {{ config.confirmationText }} +
+ } - + @if (config.alternativeConfirmationText) { + + } -
- {{ config.discardChangesText }} -
+ @if (config.discardChangesText) { +
+ {{ config.discardChangesText }} +
+ } -
- {{ config.denyText }} -
+ @if (!config.discardChangesText) { + @if (config.cancelButtonPrimary) { + + } @else { +
+ {{ config.denyText }} +
+ } + }
diff --git a/src/lib/dialog/confirmation-dialog/confirmation-dialog.component.scss b/src/lib/dialog/confirmation-dialog/confirmation-dialog.component.scss index 21c8eab..3269ccc 100644 --- a/src/lib/dialog/confirmation-dialog/confirmation-dialog.component.scss +++ b/src/lib/dialog/confirmation-dialog/confirmation-dialog.component.scss @@ -6,3 +6,12 @@ display: flex; flex-direction: column; } + +.reverse { + flex-direction: row-reverse; + justify-content: flex-end; +} + +.no-uppercase { + text-transform: unset; +} diff --git a/src/lib/dialog/confirmation-dialog/confirmation-dialog.component.ts b/src/lib/dialog/confirmation-dialog/confirmation-dialog.component.ts index 0f1c635..73eb65e 100644 --- a/src/lib/dialog/confirmation-dialog/confirmation-dialog.component.ts +++ b/src/lib/dialog/confirmation-dialog/confirmation-dialog.component.ts @@ -1,12 +1,23 @@ -import { ChangeDetectionStrategy, Component, HostListener, inject, TemplateRef } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; -import { CircleButtonComponent, IconButtonComponent, IconButtonTypes } from '../../buttons'; -import { NgForOf, NgIf, NgTemplateOutlet } from '@angular/common'; -import { MatIconModule } from '@angular/material/icon'; +import { NgTemplateOutlet } from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + HostListener, + inject, + TemplateRef, + Type, + viewChild, + ViewContainerRef, +} from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { CircleButtonComponent, IconButtonTypes } from '../../buttons'; +import { IconButtonComponent } from '../../buttons'; import { ValuesOf } from '../../utils'; export const TitleColors = { @@ -18,7 +29,7 @@ export type TitleColor = ValuesOf; export const ConfirmOptions = { CONFIRM: 1, - SECOND_CONFIRM: 2, + CONFIRM_WITH_ACTION: 2, DISCARD_CHANGES: 3, } as const; @@ -46,6 +57,9 @@ interface InternalConfirmationDialogData { readonly checkboxes: CheckBox[]; readonly checkboxesValidation: boolean; readonly toastMessage?: string; + readonly component?: Type; + readonly componentInputs?: { [key: string]: unknown }; + readonly cancelButtonPrimary?: boolean; } export type IConfirmationDialogData = Partial; @@ -63,6 +77,9 @@ function getConfig(options?: IConfirmationDialogData): InternalConfirmationDialo denyText: options?.denyText ?? _('common.confirmation-dialog.deny'), checkboxes: options?.checkboxes ?? [], checkboxesValidation: typeof options?.checkboxesValidation === 'boolean' ? options.checkboxesValidation : true, + component: options?.component, + componentInputs: options?.componentInputs, + cancelButtonPrimary: options?.cancelButtonPrimary ?? false, }; } @@ -70,12 +87,9 @@ function getConfig(options?: IConfirmationDialogData): InternalConfirmationDialo templateUrl: './confirmation-dialog.component.html', styleUrls: ['./confirmation-dialog.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, imports: [ - NgIf, MatIconModule, FormsModule, - NgForOf, MatCheckboxModule, TranslateModule, NgTemplateOutlet, @@ -84,13 +98,14 @@ function getConfig(options?: IConfirmationDialogData): InternalConfirmationDialo MatDialogModule, ], }) -export class ConfirmationDialogComponent { +export class ConfirmationDialogComponent implements AfterViewInit { readonly config = getConfig(inject(MAT_DIALOG_DATA)); inputValue = ''; showToast = false; readonly inputLabel: string; readonly confirmOptions = ConfirmOptions; readonly iconButtonTypes = IconButtonTypes; + readonly detailsComponentRef = viewChild.required('detailsComponent', { read: ViewContainerRef }); constructor( private readonly _dialogRef: MatDialogRef, @@ -110,18 +125,24 @@ export class ConfirmationDialogComponent { get confirmOption(): ConfirmOption { if (!this.config.checkboxesValidation && this.config.checkboxes[0]?.value) { - return ConfirmOptions.SECOND_CONFIRM; + return ConfirmOptions.CONFIRM_WITH_ACTION; } return ConfirmOptions.CONFIRM; } - @HostListener('window:keyup.enter') - onKeyupEnter(): void { - if (this.config.requireInput && !this.confirmationDoesNotMatch()) { - this.confirm(ConfirmOptions.CONFIRM); + @HostListener('window:keyup.enter', ['$event']) + onKeyupEnter(event: KeyboardEvent): void { + event?.stopImmediatePropagation(); + if (!this.config.requireInput || !this.confirmationDoesNotMatch()) { + if (!this.config.cancelButtonPrimary) this.confirm(ConfirmOptions.CONFIRM); + else this.deny(); } } + ngAfterViewInit() { + this.#initializeDetailsComponent(); + } + confirmationDoesNotMatch(): boolean { return this.inputValue.toLowerCase() !== this.config.confirmationText.toLowerCase(); } @@ -155,4 +176,14 @@ export class ConfirmationDialogComponent { Object.assign(obj, { [key]: value }); }); } + + #initializeDetailsComponent() { + if (!this.config.component) return; + const component = this.detailsComponentRef().createComponent(this.config.component); + if (this.config.componentInputs) { + for (const [key, value] of Object.entries(this.config.componentInputs)) { + (component.instance as any)[key] = value; + } + } + } } diff --git a/src/lib/dialog/dialog.service.ts b/src/lib/dialog/dialog.service.ts index 7654395..8cb89e8 100644 --- a/src/lib/dialog/dialog.service.ts +++ b/src/lib/dialog/dialog.service.ts @@ -1,8 +1,8 @@ -import { Injectable } from '@angular/core'; -import { MatDialog, MatDialogConfig, MatDialogRef } from '@angular/material/dialog'; import { ComponentType } from '@angular/cdk/portal'; -import { mergeMap } from 'rxjs/operators'; +import { Injectable, Type } from '@angular/core'; +import { MatDialog, MatDialogConfig, MatDialogRef } from '@angular/material/dialog'; import { from } from 'rxjs'; +import { mergeMap } from 'rxjs/operators'; export const largeDialogConfig: MatDialogConfig = { width: '90vw', @@ -64,4 +64,34 @@ export abstract class DialogService { return ref; } + + open( + type: Type, + data?: unknown, + config?: object, + cb?: (...params: unknown[]) => Promise | void, + finallyCb?: (...params: unknown[]) => void | Promise, + ): MatDialogRef { + const ref = this._dialog.open(type, { + ...defaultDialogConfig, + ...(config || {}), + data, + }); + + const fn = async (result: unknown) => { + if (result && cb) { + await cb(result); + } + + if (finallyCb) { + await finallyCb(result); + } + }; + + ref.afterClosed() + .pipe(mergeMap(result => from(fn(result)))) + .subscribe(); + + return ref; + } } diff --git a/src/lib/dialog/iqser-dialog-component.directive.ts b/src/lib/dialog/iqser-dialog-component.directive.ts index 98c866f..b4ac0d7 100644 --- a/src/lib/dialog/iqser-dialog-component.directive.ts +++ b/src/lib/dialog/iqser-dialog-component.directive.ts @@ -1,29 +1,32 @@ import { Directive, HostListener, inject } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { hasFormChanged, IqserEventTarget } from '../utils'; import { FormGroup } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { IconButtonTypes } from '../buttons'; +import { hasFormChanged, IqserEventTarget } from '../utils'; +const DIALOG_CONTAINER = 'mat-dialog-container'; const DATA_TYPE_SYMBOL = Symbol.for('DATA_TYPE'); const RETURN_TYPE_SYMBOL = Symbol.for('RETURN_TYPE'); export type DATA_TYPE = typeof DATA_TYPE_SYMBOL; export type RETURN_TYPE = typeof RETURN_TYPE_SYMBOL; -const TARGET_NODE = 'mat-dialog-container'; - @Directive() -export abstract class IqserDialogComponent { +export abstract class IqserDialogComponent { readonly [DATA_TYPE_SYMBOL]!: DataType; readonly [RETURN_TYPE_SYMBOL]!: ReturnType; + readonly iconButtonTypes = IconButtonTypes; readonly dialogRef = inject(MatDialogRef); readonly data = inject(MAT_DIALOG_DATA); readonly dialog = inject(MatDialog); readonly form?: FormGroup; + readonly ignoredKeys: string[] = []; + initialFormValue: Record = {}; - constructor() { + constructor(private readonly _editMode = false) { this.dialogRef .backdropClick() .pipe(takeUntilDestroyed()) @@ -36,7 +39,7 @@ export abstract class IqserDialogComponent } get changed(): boolean { - return !this.form || hasFormChanged(this.form, this.initialFormValue); + return !this.form || hasFormChanged(this.form, this.initialFormValue, this.ignoredKeys); } get disabled(): boolean { @@ -53,14 +56,18 @@ export abstract class IqserDialogComponent @HostListener('window:keydown.Enter', ['$event']) onEnter(event: KeyboardEvent): void { event?.stopImmediatePropagation(); - const node = (event.target as IqserEventTarget).localName?.trim()?.toLowerCase(); - if (this.onEnterValidator(event) && node === TARGET_NODE) { + if (this.onEnterValidator(event)) { this.close(); } } onEnterValidator(event: KeyboardEvent) { - return this.valid && !this.disabled && this.changed; + const targetElement = (event.target as IqserEventTarget).localName?.trim()?.toLowerCase(); + const canClose = targetElement === DIALOG_CONTAINER && this.valid; + if (this._editMode) { + return canClose && this.changed; + } + return canClose; } close(dialogResult?: ReturnType) { diff --git a/src/lib/dialog/iqser-dialog.service.ts b/src/lib/dialog/iqser-dialog.service.ts index 599d65b..806c5b8 100644 --- a/src/lib/dialog/iqser-dialog.service.ts +++ b/src/lib/dialog/iqser-dialog.service.ts @@ -13,10 +13,11 @@ export class IqserDialog { open< Component extends IqserDialogComponent, + // eslint-disable-next-line @typescript-eslint/no-unused-vars Data extends Component[DATA_TYPE] = Component[DATA_TYPE], Return extends Component[RETURN_TYPE] = Component[RETURN_TYPE], - >(dialog: ComponentType, config?: MatDialogConfig) { - const ref = this._dialog.open(dialog, config); + >(dialog: ComponentType, config?: MatDialogConfig) { + const ref = this._dialog.open(dialog, config); return { ...ref, result() { @@ -29,7 +30,7 @@ export class IqserDialog { Component extends IqserDialogComponent, Data extends Component[DATA_TYPE] = Component[DATA_TYPE], Return extends Component[RETURN_TYPE] = Component[RETURN_TYPE], - >(dialog: ComponentType, config?: MatDialogConfig) { + >(dialog: ComponentType, config?: MatDialogConfig) { return this.open(dialog, { ...largeDialogConfig, ...config }); } @@ -37,7 +38,7 @@ export class IqserDialog { Component extends IqserDialogComponent, Data extends Component[DATA_TYPE] = Component[DATA_TYPE], Return extends Component[RETURN_TYPE] = Component[RETURN_TYPE], - >(dialog: ComponentType, config?: MatDialogConfig) { + >(dialog: ComponentType, config?: MatDialogConfig) { return this.open(dialog, { ...defaultDialogConfig, ...config }); } } diff --git a/src/lib/directives/disable-stop-propagation.directive.ts b/src/lib/directives/disable-stop-propagation.directive.ts index 64d9de0..a1edccb 100644 --- a/src/lib/directives/disable-stop-propagation.directive.ts +++ b/src/lib/directives/disable-stop-propagation.directive.ts @@ -1,9 +1,8 @@ -import { booleanAttribute, Directive, Input } from '@angular/core'; +import { booleanAttribute, Directive, input } from '@angular/core'; @Directive({ selector: '[iqserDisableStopPropagation]', - standalone: true, }) export class DisableStopPropagationDirective { - @Input({ transform: booleanAttribute }) iqserDisableStopPropagation = true; + readonly iqserDisableStopPropagation = input(true, { transform: booleanAttribute }); } diff --git a/src/lib/directives/has-scrollbar.directive.ts b/src/lib/directives/has-scrollbar.directive.ts index 3b94ed5..561f7ff 100644 --- a/src/lib/directives/has-scrollbar.directive.ts +++ b/src/lib/directives/has-scrollbar.directive.ts @@ -1,18 +1,24 @@ -import { ChangeDetectorRef, Directive, ElementRef, HostBinding, HostListener, OnChanges, OnInit } from '@angular/core'; +import { Directive, ElementRef, OnDestroy, OnInit, signal } from '@angular/core'; @Directive({ selector: '[iqserHasScrollbar]', - standalone: true, + host: { + '[class]': '_class()', + }, }) -export class HasScrollbarDirective implements OnInit, OnChanges { - @HostBinding('class') class = ''; +export class HasScrollbarDirective implements OnInit, OnDestroy { + private readonly _resizeObserver: ResizeObserver; + protected readonly _class = signal(''); - constructor( - protected readonly _elementRef: ElementRef, - protected readonly _changeDetector: ChangeDetectorRef, - ) {} + constructor(protected readonly _elementRef: ElementRef) { + this._resizeObserver = new ResizeObserver(() => { + this.process(); + }); - get hasScrollbar() { + this._resizeObserver.observe(this._elementRef.nativeElement); + } + + private get _hasScrollbar() { const element = this._elementRef?.nativeElement as HTMLElement; return element.clientHeight < element.scrollHeight; } @@ -21,16 +27,12 @@ export class HasScrollbarDirective implements OnInit, OnChanges { setTimeout(() => this.process(), 0); } - @HostListener('window:resize') process() { - const newClass = this.hasScrollbar ? 'has-scrollbar' : ''; - if (this.class !== newClass) { - this.class = newClass; - this._changeDetector.markForCheck(); - } + const newClass = this._hasScrollbar ? 'has-scrollbar' : ''; + this._class.set(newClass); } - ngOnChanges() { - this.process(); + ngOnDestroy() { + this._resizeObserver.unobserve(this._elementRef.nativeElement); } } diff --git a/src/lib/directives/hidden-action.directive.ts b/src/lib/directives/hidden-action.directive.ts index 23940c6..d72391f 100644 --- a/src/lib/directives/hidden-action.directive.ts +++ b/src/lib/directives/hidden-action.directive.ts @@ -2,7 +2,6 @@ import { Directive, EventEmitter, HostListener, Input, Output } from '@angular/c @Directive({ selector: '[iqserHiddenAction]', - standalone: true, }) export class HiddenActionDirective { @Input() requiredClicks = 4; diff --git a/src/lib/directives/prevent-default.directive.ts b/src/lib/directives/prevent-default.directive.ts index a63e9d2..b083e5e 100644 --- a/src/lib/directives/prevent-default.directive.ts +++ b/src/lib/directives/prevent-default.directive.ts @@ -3,7 +3,6 @@ import { NGXLogger } from 'ngx-logger'; @Directive({ selector: '[iqserPreventDefault]', - standalone: true, }) export class PreventDefaultDirective { readonly #logger = inject(NGXLogger); diff --git a/src/lib/directives/stop-propagation.directive.ts b/src/lib/directives/stop-propagation.directive.ts index 211bfb3..481bd3c 100644 --- a/src/lib/directives/stop-propagation.directive.ts +++ b/src/lib/directives/stop-propagation.directive.ts @@ -1,10 +1,9 @@ import { booleanAttribute, Directive, HostListener, inject, Input } from '@angular/core'; -import { DisableStopPropagationDirective } from './disable-stop-propagation.directive'; import { NGXLogger } from 'ngx-logger'; +import { DisableStopPropagationDirective } from './disable-stop-propagation.directive'; @Directive({ selector: '[iqserStopPropagation]', - standalone: true, }) export class StopPropagationDirective { readonly #disableStopPropagation = inject(DisableStopPropagationDirective, { optional: true }); @@ -13,13 +12,14 @@ export class StopPropagationDirective { @HostListener('click', ['$event']) onClick($event: Event) { - if (this.#disableStopPropagation?.iqserDisableStopPropagation) { + if (this.#disableStopPropagation?.iqserDisableStopPropagation()) { this.#logger.info('[CLICK] iqserStopPropagation is disabled by iqserDisableStopPropagation'); return; } if (this.iqserStopPropagation) { this.#logger.info('[CLICK] iqserStopPropagation'); + $event.preventDefault(); $event.stopPropagation(); } } diff --git a/src/lib/directives/sync-width.directive.ts b/src/lib/directives/sync-width.directive.ts index 11d0eb9..b99cccd 100644 --- a/src/lib/directives/sync-width.directive.ts +++ b/src/lib/directives/sync-width.directive.ts @@ -2,7 +2,6 @@ import { Directive, ElementRef, HostListener, Input, OnDestroy } from '@angular/ @Directive({ selector: '[iqserSyncWidth]', - standalone: true, }) export class SyncWidthDirective implements OnDestroy { @Input() iqserSyncWidth!: string; diff --git a/src/lib/empty-state/empty-state.component.html b/src/lib/empty-state/empty-state.component.html index 49e188f..a856539 100644 --- a/src/lib/empty-state/empty-state.component.html +++ b/src/lib/empty-state/empty-state.component.html @@ -1,26 +1,22 @@ -
- +
+ @if (icon(); as icon) { + + }
-
+
- + @if (showButton() && this.action.observed) { + + }
diff --git a/src/lib/empty-state/empty-state.component.ts b/src/lib/empty-state/empty-state.component.ts index 622c31b..9e3cd93 100644 --- a/src/lib/empty-state/empty-state.component.ts +++ b/src/lib/empty-state/empty-state.component.ts @@ -1,33 +1,42 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; -import { IconButtonComponent, IconButtonTypes } from '../buttons'; -import { randomString } from '../utils'; -import { NgIf, NgStyle } from '@angular/common'; +import { NgStyle } from '@angular/common'; +import { + booleanAttribute, + ChangeDetectionStrategy, + Component, + computed, + EventEmitter, + input, + numberAttribute, + Output, +} from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; -import { IqserHelpModeModule } from '../help-mode'; +import { IconButtonComponent } from '../buttons/icon-button/icon-button.component'; +import { IconButtonTypes } from '../buttons/types/icon-button.type'; +import { randomString } from '../utils/functions'; @Component({ - selector: 'iqser-empty-state [text]', + selector: 'iqser-empty-state', templateUrl: './empty-state.component.html', styleUrls: ['./empty-state.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [NgStyle, MatIconModule, NgIf, IconButtonComponent, IqserHelpModeModule], + imports: [NgStyle, MatIconModule, IconButtonComponent], }) -export class EmptyStateComponent implements OnInit { - readonly iconButtonTypes = IconButtonTypes; +export class EmptyStateComponent { + protected readonly iconButtonTypes = IconButtonTypes; - @Input() text!: string; - @Input() icon?: string; - @Input() showButton = true; - @Input() buttonIcon = 'iqser:plus'; - @Input() buttonLabel?: string; - @Input() buttonId = `${randomString()}-icon-button`; - @Input() horizontalPadding = 100; - @Input() verticalPadding = 120; - @Input() helpModeKey?: string; + readonly text = input.required(); + readonly icon = input(); + readonly showButton = input(true, { transform: booleanAttribute }); + readonly buttonIcon = input('iqser:plus'); + readonly buttonLabel = input(); + readonly buttonId = input(`${randomString()}-icon-button`); + readonly horizontalPadding = input(100, { transform: numberAttribute }); + readonly verticalPadding = input(120, { transform: numberAttribute }); + protected readonly styles = computed(() => ({ + 'padding-top': this.verticalPadding() + 'px', + 'padding-left': this.horizontalPadding() + 'px', + 'padding-right': this.horizontalPadding() + 'px', + })); + readonly helpModeKey = input(); @Output() readonly action = new EventEmitter(); - - ngOnInit(): void { - this.showButton = this.showButton && this.action.observed; - } } diff --git a/src/lib/error/connection-status/connection-status.component.html b/src/lib/error/connection-status/connection-status.component.html index 45b1490..4c7cc8e 100644 --- a/src/lib/error/connection-status/connection-status.component.html +++ b/src/lib/error/connection-status/connection-status.component.html @@ -1,8 +1,5 @@ -
- -
+@if (connectionStatus(); as status) { +
+ +
+} diff --git a/src/lib/error/connection-status/connection-status.component.ts b/src/lib/error/connection-status/connection-status.component.ts index 04f3e36..0f8dfc2 100644 --- a/src/lib/error/connection-status/connection-status.component.ts +++ b/src/lib/error/connection-status/connection-status.component.ts @@ -1,6 +1,7 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; -import { connectionStatusTranslations } from '../../translations'; import { animate, state, style, transition, trigger } from '@angular/animations'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { connectionStatusTranslations } from '../../translations'; import { ErrorService } from '../error.service'; @Component({ @@ -16,8 +17,9 @@ import { ErrorService } from '../error.service'; ]), ], changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, }) export class ConnectionStatusComponent { - connectionStatusTranslations = connectionStatusTranslations; - protected readonly errorService = inject(ErrorService); + protected readonly connectionStatusTranslations = connectionStatusTranslations; + protected readonly connectionStatus = toSignal(inject(ErrorService).connectionStatus$); } diff --git a/src/lib/error/full-page-error/full-page-error.component.html b/src/lib/error/full-page-error/full-page-error.component.html index eb9b099..cf35e6b 100644 --- a/src/lib/error/full-page-error/full-page-error.component.html +++ b/src/lib/error/full-page-error/full-page-error.component.html @@ -1,13 +1,11 @@ - +@if (errorService.error$ | async; as error) {
-
-
- -
{{ error.message }}
- + @if (error.message) { +
{{ error.message }}
+ }
-
+} diff --git a/src/lib/error/full-page-error/full-page-error.component.ts b/src/lib/error/full-page-error/full-page-error.component.ts index 0a2f7a6..f2bbf75 100644 --- a/src/lib/error/full-page-error/full-page-error.component.ts +++ b/src/lib/error/full-page-error/full-page-error.component.ts @@ -1,18 +1,18 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; import { IconButtonTypes } from '../../buttons'; import { CustomError, ErrorService, ErrorType } from '../error.service'; -import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; @Component({ selector: 'iqser-full-page-error', templateUrl: './full-page-error.component.html', styleUrls: ['./full-page-error.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, }) export class FullPageErrorComponent { - readonly iconButtonTypes = IconButtonTypes; - - constructor(readonly errorService: ErrorService) {} + protected readonly iconButtonTypes = IconButtonTypes; + protected readonly errorService = inject(ErrorService); errorTitle(error: ErrorType): string { return error instanceof CustomError ? error.label : _('error.title'); diff --git a/src/lib/error/server-error-interceptor.ts b/src/lib/error/server-error-interceptor.ts index 98f4b46..565d5cf 100644 --- a/src/lib/error/server-error-interceptor.ts +++ b/src/lib/error/server-error-interceptor.ts @@ -8,13 +8,12 @@ import { HttpStatusCode, } from '@angular/common/http'; import { Inject, Injectable, Optional } from '@angular/core'; -import { MonoTypeOperatorFunction, Observable, retry, throwError, timer } from 'rxjs'; +import { finalize, MonoTypeOperatorFunction, Observable, retry, throwError, timer } from 'rxjs'; import { catchError, tap } from 'rxjs/operators'; import { MAX_RETRIES_ON_SERVER_ERROR, SERVER_ERROR_SKIP_PATHS } from './tokens'; import { ErrorService } from './error.service'; -import { KeycloakService } from 'keycloak-angular'; -import { IqserConfigService } from '../services'; import { KeycloakStatusService } from '../tenants'; +import { LoadingService } from '../loading'; function updateSeconds(seconds: number) { if (seconds === 0 || seconds === 1) { @@ -56,8 +55,7 @@ export class ServerErrorInterceptor implements HttpInterceptor { constructor( private readonly _errorService: ErrorService, - private readonly _keycloakService: KeycloakService, - private readonly _configService: IqserConfigService, + private readonly _loadingService: LoadingService, private readonly _keycloakStatusService: KeycloakStatusService, @Optional() @Inject(MAX_RETRIES_ON_SERVER_ERROR) private readonly _maxRetries: number, @Optional() @Inject(SERVER_ERROR_SKIP_PATHS) private readonly _skippedPaths: string[], @@ -84,7 +82,11 @@ export class ServerErrorInterceptor implements HttpInterceptor { this._urlsWithError.add(req.url); } - return throwError(() => error); + return throwError(() => error).pipe( + finalize(() => { + this._loadingService.stop(); + }), + ); }), backoffOnServerError(this._maxRetries, this._skippedPaths), ); diff --git a/src/lib/filtering/filter-card/filter-card.component.html b/src/lib/filtering/filter-card/filter-card.component.html index fae3b07..9238f21 100644 --- a/src/lib/filtering/filter-card/filter-card.component.html +++ b/src/lib/filtering/filter-card/filter-card.component.html @@ -1,42 +1,51 @@ - - +@if (primaryFilterGroup$ | async; as primaryGroup) { + @if (primaryGroup.filterceptionPlaceholder) { +
+ +
+ } -
- -
- -
-
-
+ @if (primaryFilters$ | async; as filters) { +
+ @for (filter of filters; track filter.id) { + + }
+ } - -
- + @if (secondaryFilterGroup$ | async; as secondaryGroup) { +
+
+
+
+ + @for (filter of secondaryGroup.filters; track filter.id) { + + } +
+ } +} {{ filter?.label }} @@ -44,30 +53,51 @@ -
-
-
+ @if (primaryFilterGroup$ | async; as primaryGroup) { +
-
+
+ @if (!primaryGroup.singleSelect) { +
+ } + +
+
-
+ }
-
- - -
+ @if (filter.children?.length > 0) { +
+ @if (filter.expanded) { + + } + @if (!filter.expanded) { + + } +
+ } -
 
+ @if (atLeastOneIsExpandable && filter.children?.length === 0) { +
 
+ } - +
-
-
- - - - - + @if (filter.children?.length && filter.expanded) { +
+ @for (child of filter.children; track child) { + @if (!child.hidden) { +
+ + + + +
+ } + }
-
+ } diff --git a/src/lib/filtering/filter-card/filter-card.component.scss b/src/lib/filtering/filter-card/filter-card.component.scss index b5caea8..4a73124 100644 --- a/src/lib/filtering/filter-card/filter-card.component.scss +++ b/src/lib/filtering/filter-card/filter-card.component.scss @@ -32,7 +32,7 @@ padding-bottom: 8px; } -iqser-input-with-action { +.input-wrapper { padding: 0 8px 8px 8px; } diff --git a/src/lib/filtering/filter-card/filter-card.component.ts b/src/lib/filtering/filter-card/filter-card.component.ts index 9b97328..08a7ffa 100644 --- a/src/lib/filtering/filter-card/filter-card.component.ts +++ b/src/lib/filtering/filter-card/filter-card.component.ts @@ -1,16 +1,20 @@ -import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit, TemplateRef } from '@angular/core'; +import { AsyncPipe, NgTemplateOutlet } from '@angular/common'; +import { ChangeDetectionStrategy, Component, effect, ElementRef, inject, input, numberAttribute, OnInit, TemplateRef } from '@angular/core'; +import { MAT_CHECKBOX_DEFAULT_OPTIONS, MatCheckbox } from '@angular/material/checkbox'; +import { MatIcon } from '@angular/material/icon'; +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; +import { TranslateModule } from '@ngx-translate/core'; import { combineLatest, Observable, pipe } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { StopPropagationDirective } from '../../directives/stop-propagation.directive'; +import { InputWithActionComponent } from '../../inputs/input-with-action/input-with-action.component'; +import { SearchService } from '../../search/search.service'; +import { shareDistinctLast, shareLast } from '../../utils/operators'; +import { FilterService } from '../filter.service'; +import { Filter } from '../models/filter'; +import { IFilterGroup } from '../models/filter-group.model'; import { IFilter } from '../models/filter.model'; import { INestedFilter } from '../models/nested-filter.model'; -import { IFilterGroup } from '../models/filter-group.model'; -import { extractFilterValues, handleCheckedValue } from '../filter-utils'; -import { FilterService } from '../filter.service'; -import { SearchService } from '../../search'; -import { Filter } from '../models/filter'; -import { map } from 'rxjs/operators'; -import { shareDistinctLast, shareLast } from '../../utils'; -import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; -import { MAT_CHECKBOX_DEFAULT_OPTIONS } from '@angular/material/checkbox'; const areExpandable = (nestedFilter: INestedFilter) => !!nestedFilter?.children?.length; const atLeastOneIsExpandable = pipe( @@ -18,19 +22,8 @@ const atLeastOneIsExpandable = pipe( shareDistinctLast(), ); -export interface LocalStorageFilter { - id: string; - checked: boolean; - children?: LocalStorageFilter[] | null; -} - -export interface LocalStorageFilters { - primaryFilters: LocalStorageFilter[] | null; - secondaryFilters: LocalStorageFilter[] | null; -} - @Component({ - selector: 'iqser-filter-card [primaryFiltersSlug]', + selector: 'iqser-filter-card', templateUrl: './filter-card.component.html', styleUrls: ['./filter-card.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, @@ -44,27 +37,29 @@ export interface LocalStorageFilters { }, }, ], + imports: [AsyncPipe, InputWithActionComponent, NgTemplateOutlet, TranslateModule, MatIcon, MatCheckbox, StopPropagationDirective], }) export class FilterCardComponent implements OnInit { - @Input() primaryFiltersSlug!: string; - @Input() fileId?: string; - @Input() actionsTemplate?: TemplateRef; - @Input() secondaryFiltersSlug = ''; - @Input() primaryFiltersLabel: string = _('filter-menu.filter-types'); - @Input() minWidth = 350; - + readonly #filterService = inject(FilterService); + readonly #elementRef = inject(ElementRef); + protected readonly searchService = inject>(SearchService); + readonly primaryFiltersSlug = input.required(); + readonly fileId = input(); + readonly actionsTemplate = input>(); + readonly secondaryFiltersSlug = input(''); + readonly primaryFiltersLabel = input(_('filter-menu.filter-types')); + readonly minWidth = input(350, { transform: numberAttribute }); primaryFilterGroup$!: Observable; secondaryFilterGroup$!: Observable; primaryFilters$!: Observable; - atLeastOneFilterIsExpandable$?: Observable; atLeastOneSecondaryFilterIsExpandable$?: Observable; - constructor( - readonly filterService: FilterService, - readonly searchService: SearchService, - private readonly _elementRef: ElementRef, - ) {} + constructor() { + effect(() => { + (this.#elementRef.nativeElement as HTMLElement).style.setProperty('--filter-card-min-width', `${this.minWidth()}px`); + }); + } private get _primaryFilters$(): Observable { return combineLatest([this.primaryFilterGroup$, this.searchService.valueChanges$]).pipe( @@ -74,87 +69,37 @@ export class FilterCardComponent implements OnInit { } ngOnInit() { - this.primaryFilterGroup$ = this.filterService.getGroup$(this.primaryFiltersSlug).pipe(shareLast()); - this.secondaryFilterGroup$ = this.filterService.getGroup$(this.secondaryFiltersSlug).pipe(shareLast()); + this.primaryFilterGroup$ = this.#filterService.getGroup$(this.primaryFiltersSlug()).pipe(shareLast()); + this.secondaryFilterGroup$ = this.#filterService.getGroup$(this.secondaryFiltersSlug()).pipe(shareLast()); this.primaryFilters$ = this._primaryFilters$; this.atLeastOneFilterIsExpandable$ = atLeastOneIsExpandable(this.primaryFilterGroup$); this.atLeastOneSecondaryFilterIsExpandable$ = atLeastOneIsExpandable(this.secondaryFilterGroup$); - - (this._elementRef.nativeElement as HTMLElement).style.setProperty('--filter-card-min-width', `${this.minWidth}px`); } filterCheckboxClicked(nestedFilter: INestedFilter, filterGroup: IFilterGroup, parent?: INestedFilter): void { - if (filterGroup.singleSelect) { - this.deactivateFilters(nestedFilter.id); - } + this.#filterService.filterCheckboxClicked({ + nestedFilter, + filterGroup, + parent, + primaryFiltersSlug: this.primaryFiltersSlug(), + }); + this.#filterService.updateFiltersInLocalStorage(this.fileId()); + } - // eslint-disable-next-line no-param-reassign - nestedFilter.checked = !nestedFilter.checked; - - if (parent) { - handleCheckedValue(parent); - } else { - // eslint-disable-next-line no-param-reassign - if (nestedFilter.indeterminate) { - nestedFilter.checked = false; - } - // eslint-disable-next-line no-param-reassign - nestedFilter.indeterminate = false; - // eslint-disable-next-line no-return-assign,no-param-reassign - nestedFilter.children?.forEach(f => (f.checked = !!nestedFilter.checked)); - } - - this.filterService.refresh(); - this.#updateFiltersInLocalStorage(); + deactivatePrimaryFilters() { + this.#filterService.deactivateFilters({ primaryFiltersSlug: this.primaryFiltersSlug() }); + this.#filterService.updateFiltersInLocalStorage(this.fileId()); } activatePrimaryFilters(): void { - this._setFilters(this.primaryFiltersSlug, true); - } - - deactivateFilters(exceptedFilterId?: string): void { - this._setFilters(this.primaryFiltersSlug, false, exceptedFilterId); - if (this.secondaryFiltersSlug) { - this._setFilters(this.secondaryFiltersSlug, false, exceptedFilterId); - } + this.#filterService.setFilters(this.primaryFiltersSlug(), true); + this.#filterService.updateFiltersInLocalStorage(this.fileId()); } toggleFilterExpanded(nestedFilter: INestedFilter): void { // eslint-disable-next-line no-param-reassign nestedFilter.expanded = !nestedFilter.expanded; - this.filterService.refresh(); - } - - private _setFilters(filterGroup: string, checked = false, exceptedFilterId?: string) { - const filters = this.filterService.getGroup(filterGroup)?.filters; - filters?.forEach(f => { - if (f.id !== exceptedFilterId) { - // eslint-disable-next-line no-param-reassign - f.checked = checked; - // eslint-disable-next-line no-param-reassign - f.indeterminate = false; - // eslint-disable-next-line no-return-assign,no-param-reassign - f.children?.forEach(ff => (ff.checked = checked)); - } - }); - this.filterService.refresh(); - } - - #updateFiltersInLocalStorage(): void { - if (this.fileId) { - const primaryFilters = this.filterService.getGroup('primaryFilters'); - const secondaryFilters = this.filterService.getGroup('secondaryFilters'); - - const filters: LocalStorageFilters = { - primaryFilters: extractFilterValues(primaryFilters?.filters), - secondaryFilters: extractFilterValues(secondaryFilters?.filters), - }; - - const workloadFiltersString = localStorage.getItem('workload-filters') ?? '{}'; - const workloadFilters = JSON.parse(workloadFiltersString); - workloadFilters[this.fileId] = filters; - localStorage.setItem('workload-filters', JSON.stringify(workloadFilters)); - } + this.#filterService.refresh(); } } diff --git a/src/lib/filtering/filter-utils.ts b/src/lib/filtering/filter-utils.ts index cf9abe7..eef3b4a 100644 --- a/src/lib/filtering/filter-utils.ts +++ b/src/lib/filtering/filter-utils.ts @@ -1,10 +1,11 @@ /* eslint-disable no-param-reassign */ -import { INestedFilter } from './models/nested-filter.model'; +import { IListable } from '../listing/models/listable'; +import { Id } from '../listing/models/trackable'; import { IFilterGroup } from './models/filter-group.model'; import { IFilter } from './models/filter.model'; +import { LocalStorageFilter } from './models/local-filters.model'; import { NestedFilter } from './models/nested-filter'; -import { Id, IListable } from '../listing'; -import { LocalStorageFilter } from './filter-card/filter-card.component'; +import { INestedFilter } from './models/nested-filter.model'; function copySettings(oldFilters: INestedFilter[], newFilters: INestedFilter[]) { if (!oldFilters || !newFilters) { @@ -15,6 +16,7 @@ function copySettings(oldFilters: INestedFilter[], newFilters: INestedFilter[]) const newFilter = newFilters.find(f => f.id === filter.id); if (newFilter) { newFilter.checked = filter.checked; + newFilter.expanded = filter.expanded; newFilter.indeterminate = filter.indeterminate; if (filter.children && newFilter.children) { copySettings(filter.children, newFilter.children); diff --git a/src/lib/filtering/filter.service.ts b/src/lib/filtering/filter.service.ts index f5c4f80..51694ee 100644 --- a/src/lib/filtering/filter.service.ts +++ b/src/lib/filtering/filter.service.ts @@ -1,13 +1,31 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable, Subject } from 'rxjs'; import { map, startWith, switchMap } from 'rxjs/operators'; -import { processFilters, toFlatFilters } from './filter-utils'; -import { IFilterGroup } from './models/filter-group.model'; -import { INestedFilter } from './models/nested-filter.model'; import { get, shareDistinctLast, shareLast, some } from '../utils'; -import { NestedFilter } from './models/nested-filter'; +import { extractFilterValues, handleCheckedValue, processFilters, toFlatFilters } from './filter-utils'; import { Filter } from './models/filter'; +import { IFilterGroup } from './models/filter-group.model'; import { IFilter } from './models/filter.model'; +import { LocalStorageFilters } from './models/local-filters.model'; +import { NestedFilter } from './models/nested-filter'; +import { INestedFilter } from './models/nested-filter.model'; + +export interface CheckboxClickedParams { + nestedFilter: INestedFilter; + filterGroup: IFilterGroup; + parent?: INestedFilter; + primaryFiltersSlug: string; +} + +export interface DeactivateFiltersParams { + primaryFiltersSlug: string; + secondaryFiltersSlug?: string; + exceptedFilterId?: string; +} + +const PRIMARY_FILTERS = 'primaryFilters'; +const SECONDARY_FILTERS = 'secondaryFilters'; +const WORKLOAD_FILTERS_KEY = 'workload-filters'; @Injectable() export class FilterService { @@ -196,4 +214,70 @@ export class FilterService { this.addSingleFilter(filter); } } + + setFilters(filterGroup: string, checked = false, exceptedFilterId?: string) { + const filters = this.getGroup(filterGroup)?.filters; + filters?.forEach(f => { + if (f.id !== exceptedFilterId) { + // eslint-disable-next-line no-param-reassign + f.checked = checked; + // eslint-disable-next-line no-param-reassign + f.indeterminate = false; + // eslint-disable-next-line no-return-assign,no-param-reassign + f.children?.forEach(ff => (ff.checked = checked)); + } + }); + this.refresh(); + } + + deactivateFilters(params: DeactivateFiltersParams) { + const { primaryFiltersSlug, secondaryFiltersSlug, exceptedFilterId } = params; + this.setFilters(primaryFiltersSlug, false, exceptedFilterId); + if (secondaryFiltersSlug) { + this.setFilters(secondaryFiltersSlug, false, exceptedFilterId); + } + } + + filterCheckboxClicked(params: CheckboxClickedParams) { + const { filterGroup, nestedFilter, parent, primaryFiltersSlug } = params; + + if (filterGroup.singleSelect) { + this.deactivateFilters({ primaryFiltersSlug, exceptedFilterId: nestedFilter.id }); + } + + // eslint-disable-next-line no-param-reassign + nestedFilter.checked = !nestedFilter.checked; + + if (parent) { + handleCheckedValue(parent); + } else { + // eslint-disable-next-line no-param-reassign + if (nestedFilter.indeterminate) { + nestedFilter.checked = false; + } + // eslint-disable-next-line no-param-reassign + nestedFilter.indeterminate = false; + // eslint-disable-next-line no-return-assign,no-param-reassign + nestedFilter.children?.forEach(f => (f.checked = !!nestedFilter.checked)); + } + + this.refresh(); + } + + updateFiltersInLocalStorage(fileId?: string) { + if (fileId) { + const primaryFilters = this.getGroup(PRIMARY_FILTERS); + const secondaryFilters = this.getGroup(SECONDARY_FILTERS); + + const filters: LocalStorageFilters = { + primaryFilters: extractFilterValues(primaryFilters?.filters), + secondaryFilters: extractFilterValues(secondaryFilters?.filters), + }; + + const workloadFiltersString = localStorage.getItem(WORKLOAD_FILTERS_KEY) ?? '{}'; + const workloadFilters = JSON.parse(workloadFiltersString); + workloadFilters[fileId] = filters; + localStorage.setItem(WORKLOAD_FILTERS_KEY, JSON.stringify(workloadFilters)); + } + } } diff --git a/src/lib/filtering/filters.module.ts b/src/lib/filtering/filters.module.ts index e13a6e8..ec8e941 100644 --- a/src/lib/filtering/filters.module.ts +++ b/src/lib/filtering/filters.module.ts @@ -1,19 +1,18 @@ -import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { TranslateModule } from '@ngx-translate/core'; -import { ChevronButtonComponent, IconButtonComponent } from '../buttons'; -import { PopupFilterComponent } from './popup-filter/popup-filter.component'; +import { ChevronButtonComponent } from '../buttons/chevron-button/chevron-button.component'; +import { IconButtonComponent } from '../buttons/icon-button/icon-button.component'; +import { PreventDefaultDirective } from '../directives/prevent-default.directive'; +import { StopPropagationDirective } from '../directives/stop-propagation.directive'; +import { InputWithActionComponent } from '../inputs/input-with-action/input-with-action.component'; import { QuickFiltersComponent } from './quick-filters/quick-filters.component'; -import { IqserHelpModeModule } from '../help-mode'; import { SingleFilterComponent } from './single-filter/single-filter.component'; -import { FilterCardComponent } from './filter-card/filter-card.component'; -import { MatIconModule } from '@angular/material/icon'; -import { PreventDefaultDirective, StopPropagationDirective } from '../directives'; -import { InputWithActionComponent } from '../inputs'; -const components = [QuickFiltersComponent, PopupFilterComponent, SingleFilterComponent, FilterCardComponent]; +const components = [QuickFiltersComponent, SingleFilterComponent]; @NgModule({ declarations: [...components], @@ -23,7 +22,6 @@ const components = [QuickFiltersComponent, PopupFilterComponent, SingleFilterCom MatCheckboxModule, MatMenuModule, TranslateModule, - IqserHelpModeModule, IconButtonComponent, ChevronButtonComponent, MatIconModule, diff --git a/src/lib/filtering/index.ts b/src/lib/filtering/index.ts index 75e5952..6e2c92f 100644 --- a/src/lib/filtering/index.ts +++ b/src/lib/filtering/index.ts @@ -11,3 +11,4 @@ export * from './models/nested-filter.model'; export * from './popup-filter/popup-filter.component'; export * from './quick-filters/quick-filters.component'; +export * from './simple-popup-filter/simple-popup-filter.component'; diff --git a/src/lib/filtering/models/filter.model.ts b/src/lib/filtering/models/filter.model.ts index b5fd02b..ac065b8 100644 --- a/src/lib/filtering/models/filter.model.ts +++ b/src/lib/filtering/models/filter.model.ts @@ -10,5 +10,6 @@ export interface IFilter { readonly required?: boolean; readonly disabled?: boolean; readonly helpModeKey?: string; + readonly hidden?: boolean; readonly metadata?: Record; } diff --git a/src/lib/filtering/models/filter.ts b/src/lib/filtering/models/filter.ts index 5469311..9b591f4 100644 --- a/src/lib/filtering/models/filter.ts +++ b/src/lib/filtering/models/filter.ts @@ -1,5 +1,5 @@ +import { IListable } from '../../listing/models/listable'; import { IFilter } from './filter.model'; -import { IListable } from '../../listing'; export class Filter implements IFilter, IListable { readonly id: string; @@ -10,6 +10,7 @@ export class Filter implements IFilter, IListable { readonly checker?: (obj?: unknown) => boolean; readonly skipTranslation?: boolean; readonly metadata?: Record; + readonly hidden?: boolean; checked: boolean; matches?: number; @@ -25,6 +26,7 @@ export class Filter implements IFilter, IListable { this.required = !!filter.required; this.skipTranslation = !!filter.skipTranslation; this.metadata = filter.metadata; + this.hidden = !!filter.hidden; } get searchKey(): string { diff --git a/src/lib/filtering/models/local-filters.model.ts b/src/lib/filtering/models/local-filters.model.ts new file mode 100644 index 0000000..8ac4da0 --- /dev/null +++ b/src/lib/filtering/models/local-filters.model.ts @@ -0,0 +1,10 @@ +export interface LocalStorageFilter { + id: string; + checked: boolean; + children?: LocalStorageFilter[] | null; +} + +export interface LocalStorageFilters { + primaryFilters: LocalStorageFilter[] | null; + secondaryFilters: LocalStorageFilter[] | null; +} diff --git a/src/lib/filtering/models/nested-filter.ts b/src/lib/filtering/models/nested-filter.ts index add8a83..26afa82 100644 --- a/src/lib/filtering/models/nested-filter.ts +++ b/src/lib/filtering/models/nested-filter.ts @@ -1,4 +1,4 @@ -import { IListable } from '../../listing'; +import { IListable } from '../../listing/models/listable'; import { Filter } from './filter'; import { INestedFilter } from './nested-filter.model'; @@ -8,8 +8,8 @@ export class NestedFilter extends Filter implements INestedFilter, IListable { disabled?: boolean; helpModeKey?: string; readonly children: Filter[]; - readonly skipTranslation?: boolean; - readonly metadata?: Record; + override readonly skipTranslation?: boolean; + override readonly metadata?: Record; constructor(nestedFilter: INestedFilter) { super(nestedFilter); diff --git a/src/lib/filtering/models/simple-filter-option.ts b/src/lib/filtering/models/simple-filter-option.ts new file mode 100644 index 0000000..3feee31 --- /dev/null +++ b/src/lib/filtering/models/simple-filter-option.ts @@ -0,0 +1,4 @@ +export interface SimpleFilterOption { + value: T; + label: string; +} diff --git a/src/lib/filtering/popup-filter/popup-filter.component.html b/src/lib/filtering/popup-filter/popup-filter.component.html index 815b4fe..2d71e29 100644 --- a/src/lib/filtering/popup-filter/popup-filter.component.html +++ b/src/lib/filtering/popup-filter/popup-filter.component.html @@ -1,5 +1,5 @@ - - +@if (primaryFilterGroup$ | async; as primaryGroup) { + @if (primaryGroup.icon) { - + } - + @if (!primaryGroup.icon) { - + } @@ -41,4 +41,4 @@
- +} diff --git a/src/lib/filtering/popup-filter/popup-filter.component.ts b/src/lib/filtering/popup-filter/popup-filter.component.ts index 3434d39..96861b1 100644 --- a/src/lib/filtering/popup-filter/popup-filter.component.ts +++ b/src/lib/filtering/popup-filter/popup-filter.component.ts @@ -1,16 +1,34 @@ +import { AsyncPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, Input, OnInit, TemplateRef } from '@angular/core'; +import { MatMenu, MatMenuContent, MatMenuTrigger } from '@angular/material/menu'; +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; +import { TranslateModule } from '@ngx-translate/core'; import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; import { delay, map } from 'rxjs/operators'; -import { shareDistinctLast, shareLast, some } from '../../utils'; +import { ChevronButtonComponent } from '../../buttons/chevron-button/chevron-button.component'; +import { IconButtonComponent } from '../../buttons/icon-button/icon-button.component'; +import { StopPropagationDirective } from '../../directives/stop-propagation.directive'; +import { shareDistinctLast, shareLast, some } from '../../utils/operators'; +import { FilterCardComponent } from '../filter-card/filter-card.component'; import { FilterService } from '../filter.service'; import { IFilterGroup } from '../models/filter-group.model'; -import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; @Component({ selector: 'iqser-popup-filter [primaryFiltersSlug]', templateUrl: './popup-filter.component.html', styleUrls: ['./popup-filter.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + IconButtonComponent, + AsyncPipe, + TranslateModule, + MatMenuTrigger, + ChevronButtonComponent, + MatMenu, + FilterCardComponent, + StopPropagationDirective, + MatMenuContent, + ], }) export class PopupFilterComponent implements OnInit { @Input() primaryFiltersSlug!: string; diff --git a/src/lib/filtering/quick-filters/quick-filters.component.html b/src/lib/filtering/quick-filters/quick-filters.component.html index 0352a69..64053bf 100644 --- a/src/lib/filtering/quick-filters/quick-filters.component.html +++ b/src/lib/filtering/quick-filters/quick-filters.component.html @@ -1,12 +1,13 @@ - -
- {{ filter.label }} -
-
+@if (quickFilters$ | async; as filters) { + @for (filter of filters; track filter.id) { +
+ {{ filter.label }} +
+ } +} diff --git a/src/lib/filtering/quick-filters/quick-filters.component.ts b/src/lib/filtering/quick-filters/quick-filters.component.ts index 0cac999..9afeadb 100644 --- a/src/lib/filtering/quick-filters/quick-filters.component.ts +++ b/src/lib/filtering/quick-filters/quick-filters.component.ts @@ -6,6 +6,7 @@ import { FilterService } from '../filter.service'; templateUrl: './quick-filters.component.html', styleUrls: ['./quick-filters.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, }) export class QuickFiltersComponent { readonly quickFilters$ = this.filterService.getFilterModels$('quickFilters'); diff --git a/src/lib/filtering/simple-popup-filter/simple-popup-filter.component.html b/src/lib/filtering/simple-popup-filter/simple-popup-filter.component.html new file mode 100644 index 0000000..6cfc7cd --- /dev/null +++ b/src/lib/filtering/simple-popup-filter/simple-popup-filter.component.html @@ -0,0 +1,66 @@ +@if (type() === 'text' && icon()) { + +} + +@if (type() === 'text' && !icon()) { + +} + +@if (type() === 'icon') { + +} + + +
+ +
+ +
+ +
+
+
+
+
+
+
+ +
+ @for (option of displayedOptions(); track option) { +
+ + {{ option.label }} + +
+ } +
+
+
+
diff --git a/src/lib/filtering/simple-popup-filter/simple-popup-filter.component.scss b/src/lib/filtering/simple-popup-filter/simple-popup-filter.component.scss new file mode 100644 index 0000000..6131c98 --- /dev/null +++ b/src/lib/filtering/simple-popup-filter/simple-popup-filter.component.scss @@ -0,0 +1,25 @@ +@use 'common-mixins'; + +.filter-menu-options, +.filter-menu-header { + display: flex; + justify-content: space-between; + padding: 8px 16px 16px 16px; + min-width: var(--filter-card-min-width); + + .actions { + display: flex; + gap: 8px; + } +} + +.input-wrapper { + padding: 0 8px 8px 8px; +} + +.filter-content { + max-height: 300px; + max-width: 500px; + overflow: auto; + @include common-mixins.scroll-bar; +} diff --git a/src/lib/filtering/simple-popup-filter/simple-popup-filter.component.ts b/src/lib/filtering/simple-popup-filter/simple-popup-filter.component.ts new file mode 100644 index 0000000..7570fb8 --- /dev/null +++ b/src/lib/filtering/simple-popup-filter/simple-popup-filter.component.ts @@ -0,0 +1,81 @@ +import { Component, computed, effect, input, output, signal, untracked } from '@angular/core'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { MatMenuModule } from '@angular/material/menu'; + +import { TranslateModule } from '@ngx-translate/core'; +import { CircleButtonComponent, IconButtonComponent } from '../../buttons'; +import { ChevronButtonComponent } from '../../buttons/chevron-button/chevron-button.component'; +import { StopPropagationDirective } from '../../directives'; +import { InputWithActionComponent } from '../../inputs/input-with-action/input-with-action.component'; +import { SimpleFilterOption } from '../models/simple-filter-option'; + +@Component({ + selector: 'iqser-simple-popup-filter', + templateUrl: './simple-popup-filter.component.html', + styleUrls: ['./simple-popup-filter.component.scss'], + imports: [ + MatMenuModule, + IconButtonComponent, + ChevronButtonComponent, + StopPropagationDirective, + InputWithActionComponent, + TranslateModule, + MatCheckbox, + IconButtonComponent, + CircleButtonComponent, + ], +}) +export class SimplePopupFilterComponent { + readonly options = input.required[]>(); + readonly icon = input(); + readonly label = input(); + readonly filterPlaceholder = input.required(); + readonly disabled = input(false); + readonly type = input<'text' | 'icon'>('text'); + readonly selectionChanged = output[]>(); + + readonly expanded = signal(false); + readonly selectedOptions = signal[]>([]); + readonly hasActiveFilters = computed(() => this.selectedOptions().length > 0); + readonly searchValue = signal(''); + readonly displayedOptions = computed(() => + this.options().filter(option => option.label.toLowerCase().includes(this.searchValue().toLowerCase())), + ); + + constructor() { + effect(() => { + this.selectionChanged.emit(this.selectedOptions()); + }); + + /** If the options change and the selected options are not in the new options, remove them. */ + effect( + () => { + const allOptions = this.options(); + const selectedOptions = untracked(this.selectedOptions); + + if (selectedOptions.some(selectedOption => !allOptions.find(o => o.value === selectedOption))) { + this.selectedOptions.set( + selectedOptions.filter(selectedOption => allOptions.find(o => o.value === selectedOption.value)), + ); + } + }, + { allowSignalWrites: true }, + ); + } + + protected _selectAll(): void { + this.selectedOptions.set(untracked(this.options)); + } + + protected _clear(): void { + this.selectedOptions.set([]); + } + + protected _filterCheckboxClicked(option: SimpleFilterOption): void { + if (this.selectedOptions().includes(option)) { + this.selectedOptions.set(this.selectedOptions().filter(selectedOption => selectedOption !== option)); + } else { + this.selectedOptions.set([...this.selectedOptions(), option]); + } + } +} diff --git a/src/lib/filtering/single-filter/single-filter.component.ts b/src/lib/filtering/single-filter/single-filter.component.ts index 7deb258..e6ecd8d 100644 --- a/src/lib/filtering/single-filter/single-filter.component.ts +++ b/src/lib/filtering/single-filter/single-filter.component.ts @@ -7,6 +7,7 @@ import { IFilter } from '../models/filter.model'; selector: 'iqser-single-filter', templateUrl: './single-filter.component.html', styleUrls: ['./single-filter.component.scss'], + standalone: false, }) export class SingleFilterComponent { @Input() filter!: IFilter; diff --git a/src/lib/help-mode/help-button/help-button.component.html b/src/lib/help-mode/help-button/help-button.component.html index 45fbb64..68369e2 100644 --- a/src/lib/help-mode/help-button/help-button.component.html +++ b/src/lib/help-mode/help-button/help-button.component.html @@ -1,6 +1,15 @@ - + diff --git a/src/lib/help-mode/help-button/help-button.component.scss b/src/lib/help-mode/help-button/help-button.component.scss new file mode 100644 index 0000000..bc3c6d9 --- /dev/null +++ b/src/lib/help-mode/help-button/help-button.component.scss @@ -0,0 +1,65 @@ +:host { + display: flex; + align-items: center; + width: 40px; + height: 24px; +} + +.help-mode-slide-toggle { + display: inline-block; + position: relative; + width: 40px; + height: 24px; + cursor: pointer; + + &.active, + &.dialog-toggle { + z-index: 1200; + } + + .toggle-input { + display: none; + } + + .toggle-track { + position: relative; + display: flex; + align-items: center; + justify-content: start; + width: 100%; + height: 25px; + background-color: var(--iqser-grey-4); + border-radius: 20px; + } + + .toggle-thumb { + margin-left: 6%; + width: 20px; + height: 20px; + background-color: #fff; + border-radius: 50%; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + transition: margin-left 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + + mat-icon { + transform: scale(0.6); + } + + .active-thumb { + color: var(--iqser-helpmode-primary); + } + } + + .toggle-input:checked + .toggle-track { + background: var(--iqser-helpmode-primary); + } + + .toggle-input:checked + .toggle-track { + .toggle-thumb { + margin-left: 43.5%; + } + } +} diff --git a/src/lib/help-mode/help-button/help-button.component.ts b/src/lib/help-mode/help-button/help-button.component.ts index e924372..b4deebf 100644 --- a/src/lib/help-mode/help-button/help-button.component.ts +++ b/src/lib/help-mode/help-button/help-button.component.ts @@ -1,31 +1,95 @@ /* eslint-disable @angular-eslint/prefer-on-push-component-change-detection */ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; -import { HelpModeService } from '../index'; +import { Component, effect, ElementRef, HostListener, Input, OnDestroy, OnInit, viewChild } from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { HelpModeService } from '../help-mode.service'; +import { MatTooltip } from '@angular/material/tooltip'; +import { TranslateService } from '@ngx-translate/core'; +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; @Component({ selector: 'iqser-help-button', templateUrl: './help-button.component.html', + styleUrls: ['./help-button.component.scss'], + imports: [MatIcon, MatTooltip], }) export class HelpButtonComponent implements OnInit, OnDestroy { - @Input() dialogButton = false; - @Input() helpButtonKey?: string; + #helpModeHasBeenActivated = false; + readonly helpModeButton = viewChild.required('helpModeButton'); + @Input() dialogButton = true; - constructor(private readonly _helpModeService: HelpModeService) {} - - ngOnInit(): void { - this._helpModeService.helpButtonKey = this.helpButtonKey; + constructor( + private readonly _elementRef: ElementRef, + private readonly _translateService: TranslateService, + readonly helpModeService: HelpModeService, + ) { + effect(() => { + if (this.helpModeService.isHelpModeActive()) { + this.#helpModeHasBeenActivated = true; + setTimeout(() => this.#applyActiveButtonStyles(), 300); + } else if (this.#helpModeHasBeenActivated) { + setTimeout(() => this.#applyInactiveButtonStyles(), 300); + } + }); } - ngOnDestroy(): void { - this._helpModeService.helpButtonKey = undefined; + get buttonTooltip() { + const translation = this.helpModeService.isHelpModeActive() ? _('help-button.disable') : _('help-button.enable'); + return this._translateService.instant(translation); } - activateHelpMode(): void { - if (this.helpButtonKey) { - const url = this._helpModeService.generateDocsLink(this.helpButtonKey); - window.open(url, '_blank'); + get buttonId() { + return `help-mode-button${this.dialogButton ? '-dialog' : ''}`; + } + + get currentComponentRect() { + return this._elementRef.nativeElement.getBoundingClientRect(); + } + + @HostListener('window:resize', ['$event']) + onResize() { + if (this.helpModeService.isHelpModeActive()) this.#applyActiveButtonStyles(); + } + + ngOnInit() { + if (this.dialogButton) { + const defaultButton = document.getElementById('help-mode-button') as HTMLElement; + defaultButton.style.setProperty('z-index', '100'); + } + } + + ngOnDestroy() { + if (this.dialogButton) { + const defaultButton = document.getElementById('help-mode-button') as HTMLElement; + defaultButton.style.removeProperty('z-index'); + + if (!this.helpModeService.isHelpModeActive()) { + const helpButtonElement = document.querySelectorAll('iqser-help-button')[this.dialogButton ? 1 : 0]; + if (helpButtonElement.contains(this.helpModeButton().nativeElement)) + helpButtonElement?.removeChild(this.helpModeButton().nativeElement); + } + } + } + + toggleHelpMode(): void { + if (this.helpModeService.isHelpModeActive()) { + this.helpModeService.deactivateHelpMode(); return; } - this._helpModeService.activateHelpMode(this.dialogButton); + this.helpModeService.activateHelpMode(this.dialogButton); + } + + #applyActiveButtonStyles() { + this.helpModeButton().nativeElement.style.setProperty('position', 'absolute'); + this.helpModeButton().nativeElement.style.setProperty('top', `${this.currentComponentRect.top}px`); + this.helpModeButton().nativeElement.style.setProperty('left', `${this.currentComponentRect.left}px`); + document.body.appendChild(this.helpModeButton().nativeElement); + } + + #applyInactiveButtonStyles() { + this.helpModeButton().nativeElement.style.setProperty('position', 'relative'); + this.helpModeButton().nativeElement.style.setProperty('top', 'unset'); + this.helpModeButton().nativeElement.style.setProperty('left', 'unset'); + document.body.removeChild(this.helpModeButton().nativeElement); + document.querySelectorAll('iqser-help-button')[this.dialogButton ? 1 : 0]?.appendChild(this.helpModeButton().nativeElement); } } diff --git a/src/lib/help-mode/help-mode-dialog/help-mode-dialog.component.html b/src/lib/help-mode/help-mode-dialog/help-mode-dialog.component.html index 42884bb..4fbffad 100644 --- a/src/lib/help-mode/help-mode-dialog/help-mode-dialog.component.html +++ b/src/lib/help-mode/help-mode-dialog/help-mode-dialog.component.html @@ -3,6 +3,9 @@

+ + {{ 'help-mode.options.do-not-show-again' | translate }} +
- +
diff --git a/src/lib/help-mode/help-mode-dialog/help-mode-dialog.component.ts b/src/lib/help-mode/help-mode-dialog/help-mode-dialog.component.ts index 8d25698..76fb615 100644 --- a/src/lib/help-mode/help-mode-dialog/help-mode-dialog.component.ts +++ b/src/lib/help-mode/help-mode-dialog/help-mode-dialog.component.ts @@ -1,4 +1,9 @@ -import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, HostListener, OnDestroy, OnInit } from '@angular/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { Subscription } from 'rxjs'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { CircleButtonComponent } from '../../buttons'; +import { TranslateModule } from '@ngx-translate/core'; const HIGHER_CDK_OVERLAY_CONTAINER_ZINDEX = '1200'; const DEFAULT_CDK_OVERLAY_CONTAINER_ZINDEX = '800'; @@ -7,17 +12,36 @@ const DEFAULT_CDK_OVERLAY_CONTAINER_ZINDEX = '800'; templateUrl: './help-mode-dialog.component.html', styleUrls: ['./help-mode-dialog.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatCheckbox, CircleButtonComponent, TranslateModule], }) export class HelpModeDialogComponent implements OnInit, OnDestroy { + #backdropClickSubscription: Subscription; + protected doNotShowAgainOption = false; + + constructor(protected readonly _dialogRef: MatDialogRef) { + this.#backdropClickSubscription = this._dialogRef.backdropClick().subscribe(() => this.close()); + } + + @HostListener('window:keydown.Enter', ['$event']) + @HostListener('window:keydown.Escape', ['$event']) + close() { + return this._dialogRef.close(this.doNotShowAgainOption); + } + ngOnInit(): void { - this._setCdkOverlayContainerZindex(HIGHER_CDK_OVERLAY_CONTAINER_ZINDEX); + this._setCdkOverlayContainerZIndex(HIGHER_CDK_OVERLAY_CONTAINER_ZINDEX); } ngOnDestroy(): void { - this._setCdkOverlayContainerZindex(DEFAULT_CDK_OVERLAY_CONTAINER_ZINDEX); + this._setCdkOverlayContainerZIndex(DEFAULT_CDK_OVERLAY_CONTAINER_ZINDEX); + this.#backdropClickSubscription.unsubscribe(); } - private _setCdkOverlayContainerZindex(zIndex: string): void { + setDoNotShowAgainOption(checked: boolean): void { + this.doNotShowAgainOption = checked; + } + + private _setCdkOverlayContainerZIndex(zIndex: string): void { const cdkOverlayContainer = document.querySelector('.cdk-overlay-container'); if (cdkOverlayContainer) { cdkOverlayContainer.style.zIndex = zIndex; diff --git a/src/lib/help-mode/help-mode.module.ts b/src/lib/help-mode/help-mode.module.ts deleted file mode 100644 index f0250c7..0000000 --- a/src/lib/help-mode/help-mode.module.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ModuleWithProviders, NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { TranslateModule } from '@ngx-translate/core'; -import { HelpModeDialogComponent } from './help-mode-dialog/help-mode-dialog.component'; -import { HelpModeComponent } from './help-mode/help-mode.component'; -import { HelpButtonComponent } from './help-button/help-button.component'; -import { HelpModeKey, HelpModeService } from './help-mode.service'; -import { MatDialogModule } from '@angular/material/dialog'; -import { CircleButtonComponent } from '../buttons'; -import { HELP_MODE_KEYS } from './tokens'; - -const components = [HelpModeComponent, HelpModeDialogComponent, HelpButtonComponent]; - -@NgModule({ - declarations: [...components], - imports: [CommonModule, MatDialogModule, TranslateModule, CircleButtonComponent], - exports: [...components], -}) -export class IqserHelpModeModule { - static forRoot(helpModeKeys: HelpModeKey[]): ModuleWithProviders { - return { - ngModule: IqserHelpModeModule, - providers: [{ provide: HELP_MODE_KEYS, useValue: helpModeKeys }, HelpModeService], - }; - } -} diff --git a/src/lib/help-mode/help-mode.service.ts b/src/lib/help-mode/help-mode.service.ts index 287a047..38cc90c 100644 --- a/src/lib/help-mode/help-mode.service.ts +++ b/src/lib/help-mode/help-mode.service.ts @@ -1,22 +1,26 @@ import { Inject, Injectable, Renderer2, RendererFactory2 } from '@angular/core'; -import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { MatDialog } from '@angular/material/dialog'; import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, firstValueFrom } from 'rxjs'; +import { getConfig } from '../services'; +import { IqserUserPreferenceService } from '../services'; import { HelpModeDialogComponent } from './help-mode-dialog/help-mode-dialog.component'; import { HELP_MODE_KEYS, MANUAL_BASE_URL } from './tokens'; +import { HelpModeKey } from './types'; import { - ANNOTATIONS_LIST_ID, + DOCUMINE_THEME_CLASS, HELP_HIGHLIGHT_CLASS, HELP_MODE_CLASS, OVERLAPPING_DROPDOWNS_IDS, OverlappingElement, PDF_TRON_IFRAME_ID, SCROLL_BUTTONS_IDS, + SCROLLABLE_PARENT_VIEWS_IDS, ScrollableParentView, ScrollableParentViews, - VIRTUAL_SCROLL_ID, WEB_VIEWER_ELEMENTS, } from './utils/constants'; +import { toSignal } from '@angular/core/rxjs-interop'; export interface Helper { readonly element: HTMLElement; @@ -27,24 +31,17 @@ export interface Helper { readonly iframeElement?: boolean; } -export interface HelpModeKey { - readonly elementKey: string; - readonly documentKey: string; - readonly scrollableParentView?: ScrollableParentView; - readonly overlappingElements?: OverlappingElement[]; - readonly dialogElement?: boolean; -} - @Injectable() export class HelpModeService { - helpButtonKey: string | undefined; readonly #isHelpModeActive$ = new BehaviorSubject(false); - readonly isHelpModeActive$ = this.#isHelpModeActive$.asObservable(); readonly #helpModeDialogIsOpened$ = new BehaviorSubject(false); - readonly helpModeDialogIsOpened$ = this.#helpModeDialogIsOpened$.asObservable(); readonly #renderer: Renderer2; + readonly #isDocumine = getConfig().IS_DOCUMINE; #helpers: Record = {}; #dialogMode = false; + readonly isHelpModeActive$ = this.#isHelpModeActive$.asObservable(); + readonly isHelpModeActive = toSignal(this.isHelpModeActive$, { initialValue: false }); + readonly helpModeDialogIsOpened$ = this.#helpModeDialogIsOpened$.asObservable(); constructor( @Inject(HELP_MODE_KEYS) private readonly _keys: HelpModeKey[], @@ -52,33 +49,34 @@ export class HelpModeService { private readonly _dialog: MatDialog, private readonly _rendererFactory: RendererFactory2, private readonly _translateService: TranslateService, + private readonly _iqserUserPreferenceService: IqserUserPreferenceService, ) { this.#renderer = this._rendererFactory.createRenderer(null, null); } - get isHelpModeActive(): boolean { - return this.#isHelpModeActive$.getValue(); - } - get helpModeDialogIsOpened(): boolean { return this.#helpModeDialogIsOpened$.getValue(); } - openHelpModeDialog(): MatDialogRef { - this.#helpModeDialogIsOpened$.next(true); + async openHelpModeDialog() { + if (!this._iqserUserPreferenceService.getHelpModeDialog()) { + this.#helpModeDialogIsOpened$.next(true); - const ref = this._dialog.open(HelpModeDialogComponent, { - width: '600px', - }); + const ref = this._dialog.open(HelpModeDialogComponent, { + width: '600px', + }); - firstValueFrom(ref.afterClosed()).then(() => { - this.#helpModeDialogIsOpened$.next(false); - }); - return ref; + firstValueFrom(ref.afterClosed()).then(result => { + this.#helpModeDialogIsOpened$.next(false); + if (result) { + this._iqserUserPreferenceService.toggleHelpModeDialog(); + } + }); + } } activateHelpMode(dialogMode: boolean = false): void { - if (!this.isHelpModeActive) { + if (!this.isHelpModeActive()) { document.body.style.setProperty('overflow', 'unset'); this.#isHelpModeActive$.next(true); this.openHelpModeDialog(); @@ -92,7 +90,7 @@ export class HelpModeService { } deactivateHelpMode(): void { - if (this.isHelpModeActive) { + if (this.isHelpModeActive()) { document.body.style.removeProperty('overflow'); this.#isHelpModeActive$.next(false); this.#disableHelperElements(); @@ -100,6 +98,19 @@ export class HelpModeService { } } + highlightHelperElements(): void { + Object.values(this.#helpers).forEach(helper => { + this.#renderer.addClass(helper.helperElement, HELP_HIGHLIGHT_CLASS); + setTimeout(() => { + this.#renderer.removeClass(helper.helperElement, HELP_HIGHLIGHT_CLASS); + }, 500); + }); + } + + updateHelperElements() { + Object.values(this.#helpers).forEach(helper => this.#updateHelperElement(helper)); + } + #createHelpers() { for (const key of Object.values(this._keys)) { const elements = document.querySelectorAll(`[help-mode-key='${key.elementKey}']`); @@ -135,9 +146,12 @@ export class HelpModeService { #getHelperElement(element: HTMLElement, key: string): HTMLElement { const helperElement = this.#renderer.createElement('a') as HTMLElement; - this.#renderer.setAttribute(helperElement, 'href', this.generateDocsLink(key)); + this.#renderer.setAttribute(helperElement, 'href', this.#generateDocsLink(key)); this.#renderer.setAttribute(helperElement, 'target', '_blank'); this.#renderer.addClass(helperElement, HELP_MODE_CLASS); + if (this.#isDocumine) { + this.#renderer.addClass(helperElement, DOCUMINE_THEME_CLASS); + } return helperElement; } @@ -145,24 +159,11 @@ export class HelpModeService { return Math.random().toString(36).substring(2, 9); } - generateDocsLink(key: string) { + #generateDocsLink(key: string) { const currentLang = this._translateService.currentLang; return `${this._manualBaseURL}/${currentLang}/index-${currentLang}.html?contextId=${key}`; } - highlightHelperElements(): void { - Object.values(this.#helpers).forEach(helper => { - this.#renderer.addClass(helper.helperElement, HELP_HIGHLIGHT_CLASS); - setTimeout(() => { - this.#renderer.removeClass(helper.helperElement, HELP_HIGHLIGHT_CLASS); - }, 500); - }); - } - - updateHelperElements() { - Object.values(this.#helpers).forEach(helper => this.#updateHelperElement(helper)); - } - #isElementVisible(helper: Helper): boolean { if (helper.iframeElement && !this.#isFilePreviewPage()) { return false; @@ -174,8 +175,7 @@ export class HelpModeService { } if (helper.scrollableParentView) { - const scrollableElementId = - helper.scrollableParentView === ScrollableParentViews.VIRTUAL_SCROLL ? VIRTUAL_SCROLL_ID : ANNOTATIONS_LIST_ID; + const scrollableElementId = SCROLLABLE_PARENT_VIEWS_IDS[helper.scrollableParentView]; const scrollableElement: HTMLElement = document.getElementById(scrollableElementId); if (!scrollableElement) { @@ -242,6 +242,7 @@ export class HelpModeService { const iframe: HTMLIFrameElement = document.getElementById(PDF_TRON_IFRAME_ID) as HTMLIFrameElement; const iframeRect = iframe.getBoundingClientRect(); dimensions.y += iframeRect.top; + dimensions.x += iframeRect.left; } helper.helperElement.style.cssText = ` diff --git a/src/lib/help-mode/help-mode/help-mode.component.html b/src/lib/help-mode/help-mode/help-mode.component.html index 0f59d6e..f9d996c 100644 --- a/src/lib/help-mode/help-mode/help-mode.component.html +++ b/src/lib/help-mode/help-mode/help-mode.component.html @@ -1,20 +1,23 @@ -
-
-
- {{ 'help-mode.bottom-text' | translate }} - - {{ 'help-mode.instructions' | translate }} - -
- (esc) - +@if (helpModeService.isHelpModeActive$ | async) { +
+
+
+ {{ 'help-mode.bottom-text' | translate }} + @if ((helpModeService.helpModeDialogIsOpened$ | async) === false) { + + {{ 'help-mode.instructions' | translate }} + + } +
+ (esc) + +
-
+} diff --git a/src/lib/help-mode/help-mode/help-mode.component.ts b/src/lib/help-mode/help-mode/help-mode.component.ts index 6ff53e3..7a8d6d2 100644 --- a/src/lib/help-mode/help-mode/help-mode.component.ts +++ b/src/lib/help-mode/help-mode/help-mode.component.ts @@ -2,13 +2,16 @@ import { ChangeDetectionStrategy, Component, HostListener } from '@angular/core' import { HelpModeService } from '../help-mode.service'; import { IqserEventTarget } from '../../utils'; import { MatDialog } from '@angular/material/dialog'; -import { CircleButtonTypes } from '../../buttons'; +import { CircleButtonComponent, CircleButtonTypes } from '../../buttons'; +import { AsyncPipe } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; @Component({ selector: 'iqser-help-mode', templateUrl: './help-mode.component.html', styleUrls: ['./help-mode.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + imports: [AsyncPipe, TranslateModule, CircleButtonComponent], }) export class HelpModeComponent { readonly circleButtonTypes = CircleButtonTypes; @@ -20,7 +23,7 @@ export class HelpModeComponent { @HostListener('document:keydown.escape', ['$event']) onEscKeydownHandler(event: KeyboardEvent): void { - if (!this.helpModeService.helpModeDialogIsOpened && this.helpModeService.isHelpModeActive) { + if (!this.helpModeService.helpModeDialogIsOpened && this.helpModeService.isHelpModeActive()) { event?.stopPropagation(); this.helpModeService.deactivateHelpMode(); } @@ -29,12 +32,7 @@ export class HelpModeComponent { @HostListener('document:keydown.h', ['$event']) onHKeydownHandler(event: KeyboardEvent): void { const node = (event.target as IqserEventTarget).localName; - if (!this.helpModeService.isHelpModeActive && node !== 'input' && node !== 'textarea') { - if (this.helpModeService.helpButtonKey) { - const url = this.helpModeService.generateDocsLink(this.helpModeService.helpButtonKey); - window.open(url, '_blank'); - return; - } + if (!this.helpModeService.isHelpModeActive() && node !== 'input' && node !== 'textarea') { const dialogMode = !!this._dialog.openDialogs.length; this.helpModeService.activateHelpMode(dialogMode); } @@ -42,14 +40,14 @@ export class HelpModeComponent { @HostListener('click') onClick(): void { - if (this.helpModeService.isHelpModeActive) { + if (this.helpModeService.isHelpModeActive()) { this.helpModeService.highlightHelperElements(); } } @HostListener('window:resize') onResize() { - if (this.helpModeService.isHelpModeActive) { + if (this.helpModeService.isHelpModeActive()) { this.helpModeService.updateHelperElements(); } } diff --git a/src/lib/help-mode/index.ts b/src/lib/help-mode/index.ts index ab6c4be..f8a16c0 100644 --- a/src/lib/help-mode/index.ts +++ b/src/lib/help-mode/index.ts @@ -1,7 +1,7 @@ export * from './tokens'; -export * from './help-mode.module'; export * from './help-mode.service'; export * from './help-mode/help-mode.component'; export * from './help-button/help-button.component'; export * from './help-mode-dialog/help-mode-dialog.component'; export * from './utils/constants'; +export * from './utils/help-mode.provider'; diff --git a/src/lib/help-mode/tokens.ts b/src/lib/help-mode/tokens.ts index c842b24..46546ea 100644 --- a/src/lib/help-mode/tokens.ts +++ b/src/lib/help-mode/tokens.ts @@ -1,6 +1,6 @@ import { inject, InjectionToken } from '@angular/core'; -import { IqserConfigService } from '../services'; -import { HelpModeKey } from './help-mode.service'; +import { IqserConfigService } from '../services/iqser-config.service'; +import { HelpModeKey } from './types'; export const HELP_MODE_KEYS = new InjectionToken('Help mode keys'); export const MANUAL_BASE_URL = new InjectionToken('Base manual URL', { diff --git a/src/lib/help-mode/types.ts b/src/lib/help-mode/types.ts new file mode 100644 index 0000000..6be4340 --- /dev/null +++ b/src/lib/help-mode/types.ts @@ -0,0 +1,9 @@ +import { OverlappingElement, ScrollableParentView } from './utils/constants'; + +export interface HelpModeKey { + readonly elementKey: string; + readonly documentKey: string; + readonly scrollableParentView?: ScrollableParentView; + readonly overlappingElements?: OverlappingElement[]; + readonly dialogElement?: boolean; +} diff --git a/src/lib/help-mode/utils/constants.ts b/src/lib/help-mode/utils/constants.ts index d97a3cf..b0cbf3e 100644 --- a/src/lib/help-mode/utils/constants.ts +++ b/src/lib/help-mode/utils/constants.ts @@ -1,22 +1,32 @@ -export const VIRTUAL_SCROLL_ID = 'virtual-scroll'; -export const ANNOTATIONS_LIST_ID = 'annotations-list'; export const OVERLAPPING_DROPDOWNS_IDS = { USER_MENU: 'user-menu-items', WORKLOAD_FILTER: 'workload-filters', DOCUMENT_INFO: 'document-info', + BREADCRUMBS_MENU: 'breadcrumbs-menu-items', }; export const SCROLL_BUTTONS_IDS = ['scroll-up', 'scroll-down']; export const PDF_TRON_IFRAME_ID = 'webviewer-1'; export const WEB_VIEWER_ELEMENTS = [ { querySelector: '.HeaderItems', - documentKey: 'pdf_features', + documentKey: 'document_viewer_features', }, ]; export const ScrollableParentViews = { VIRTUAL_SCROLL: 'VIRTUAL_SCROLL', ANNOTATIONS_LIST: 'ANNOTATIONS_LIST', + SCM_EDIT_DIALOG: 'SCM_EDIT_DIALOG', + WORKFLOW_VIEW: 'WORKFLOW_VIEW', + COMPONENTS_VIEW: 'COMPONENTS_VIEW', +} as const; + +export const SCROLLABLE_PARENT_VIEWS_IDS = { + VIRTUAL_SCROLL: 'virtual-scroll', + ANNOTATIONS_LIST: 'annotations-list', + SCM_EDIT_DIALOG: 'scm-edit', + WORKFLOW_VIEW: 'workflow-view', + COMPONENTS_VIEW: 'components-view', } as const; export type ScrollableParentView = keyof typeof ScrollableParentViews; @@ -25,9 +35,11 @@ export const OverlappingElements = { USER_MENU: 'USER_MENU', WORKLOAD_FILTER: 'WORKLOAD_FILTER', DOCUMENT_INFO: 'DOCUMENT_INFO', + BREADCRUMBS_MENU: 'BREADCRUMBS_MENU', } as const; export type OverlappingElement = keyof typeof OverlappingElements; export const HELP_MODE_CLASS = 'help-mode'; +export const DOCUMINE_THEME_CLASS = 'documine-theme'; export const HELP_HIGHLIGHT_CLASS = 'help-highlight'; diff --git a/src/lib/help-mode/utils/help-mode.provider.ts b/src/lib/help-mode/utils/help-mode.provider.ts new file mode 100644 index 0000000..cd1804b --- /dev/null +++ b/src/lib/help-mode/utils/help-mode.provider.ts @@ -0,0 +1,7 @@ +import { HelpModeService } from '../help-mode.service'; +import { HELP_MODE_KEYS } from '../tokens'; +import { HelpModeKey } from '../types'; + +export function provideHelpMode(helpModeKeys: HelpModeKey[]) { + return [{ provide: HELP_MODE_KEYS, useValue: helpModeKeys }, HelpModeService]; +} diff --git a/src/lib/inputs/details-radio/details-radio-option.ts b/src/lib/inputs/details-radio/details-radio-option.ts index d123db7..4712dd7 100644 --- a/src/lib/inputs/details-radio/details-radio-option.ts +++ b/src/lib/inputs/details-radio/details-radio-option.ts @@ -1,18 +1,29 @@ -interface ExtraOption { +interface AdditionalField { label: string; - checked: boolean; + description?: string; +} + +interface AdditionalCheck extends AdditionalField { + checked?: boolean; hidden?: boolean; disabled?: boolean; } +interface AdditionalInput extends AdditionalField { + value: string; + placeholder?: string; + errorCode?: string; +} + export interface DetailsRadioOption { id?: string; label: string; description: string; - descriptionParams?: Record; + descriptionParams?: Record; icon?: string; value: I; disabled?: boolean; tooltip?: string; - extraOption?: ExtraOption; + additionalCheck?: AdditionalCheck; + additionalInput?: AdditionalInput; } diff --git a/src/lib/inputs/details-radio/details-radio.component.html b/src/lib/inputs/details-radio/details-radio.component.html index aae9694..d0dbd64 100644 --- a/src/lib/inputs/details-radio/details-radio.component.html +++ b/src/lib/inputs/details-radio/details-radio.component.html @@ -1,45 +1,83 @@ -
-
-
- +
+ @for (option of options(); track option) { +
+ @if (option.icon) { +
+ +
+ -
- - {{ option.description | translate: option.descriptionParams | replaceNbsp }} + {{ option.description | translate: option.descriptionParams | replaceNbsp }} -
- - {{ option.extraOption.label | translate }} - + @if (isSelected(option)) { + @if (option.additionalCheck && !option.additionalCheck.hidden) { +
+ + {{ option.additionalCheck.label | translate | replaceNbsp }} + + + @if (option.additionalCheck.description) { + + } +
+ } + + @if (option.additionalInput) { +
+ {{ option.additionalInput.label | translate }} +
+ + @if (option.additionalInput.description) { + + } +
+
+ } + } +
+ + @if (isSelected(option)) { + + }
-
+ } @else { +
+ - + +
+ + {{ option.description | translate | replaceNbsp }} + }
- - -
- - -
- - {{ option.description | translate }} -
-
+ }
diff --git a/src/lib/inputs/details-radio/details-radio.component.scss b/src/lib/inputs/details-radio/details-radio.component.scss index f9945fb..667cc4d 100644 --- a/src/lib/inputs/details-radio/details-radio.component.scss +++ b/src/lib/inputs/details-radio/details-radio.component.scss @@ -42,6 +42,45 @@ label { color: var(--iqser-primary); } } + + .additional-check-description { + margin-left: 23px; + opacity: 0.49; + } + + .additional-input { + display: flex; + flex-direction: row; + gap: 10px; + + span { + margin-top: 8px; + font-size: 12px; + } + + div { + .error { + border-color: var(--iqser-red-1); + } + + display: flex; + + span { + font-size: 10px; + margin-top: 4px; + } + } + + .flex-column { + flex: 1; + + input { + width: 232px; + min-height: 30px; + height: 30px; + } + } + } } .row { diff --git a/src/lib/inputs/details-radio/details-radio.component.ts b/src/lib/inputs/details-radio/details-radio.component.ts index adff6b6..2584ea1 100644 --- a/src/lib/inputs/details-radio/details-radio.component.ts +++ b/src/lib/inputs/details-radio/details-radio.component.ts @@ -1,5 +1,5 @@ -import { NgClass, NgForOf, NgIf } from '@angular/common'; -import { booleanAttribute, Component, EventEmitter, Input, Output } from '@angular/core'; +import { NgClass } from '@angular/common'; +import { booleanAttribute, Component, input, output } from '@angular/core'; import { FormsModule, NG_VALIDATORS, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatIconModule } from '@angular/material/icon'; @@ -14,7 +14,6 @@ import { DetailsRadioOption } from './details-radio-option'; selector: 'iqser-details-radio', templateUrl: './details-radio.component.html', styleUrls: ['./details-radio.component.scss'], - standalone: true, providers: [ { provide: NG_VALUE_ACCESSOR, @@ -28,12 +27,10 @@ import { DetailsRadioOption } from './details-radio-option'; }, ], imports: [ - NgForOf, NgClass, RoundCheckboxComponent, TranslateModule, MatIconModule, - NgIf, FormsModule, MatCheckboxModule, ReactiveFormsModule, @@ -42,12 +39,12 @@ import { DetailsRadioOption } from './details-radio-option'; ], }) export class DetailsRadioComponent extends FormFieldComponent> { - @Input({ required: true }) options: DetailsRadioOption[] = []; - @Input({ transform: booleanAttribute }) displayInRow = false; + readonly options = input.required[]>(); + readonly displayInRow = input(false, { transform: booleanAttribute }); + readonly extraOptionChanged = output>(); + additionalInputTouched = false; - @Output() readonly extraOptionChanged: EventEmitter> = new EventEmitter(); - - toggleOption(option: DetailsRadioOption): void { + toggleOption(option: DetailsRadioOption) { if (option.value !== this._value?.value && !option.disabled) { this.markAsTouched(); const currentlyChecked = this.value?.value === option.value; @@ -56,15 +53,20 @@ export class DetailsRadioComponent extends FormFieldComponent): string { + groupId(option: DetailsRadioOption) { return (option.id ?? option.label.replace('.', '-')) + '-checkbox-details-input'; } - isSelected(option: DetailsRadioOption): boolean { + isSelected(option: DetailsRadioOption) { return option.value === this.value?.value; } - emitExtraOption(): void { + emitExtraOption() { + if (!this.value) { + console.error('Extra option selected but the value is undefined'); + return; + } + this.extraOptionChanged.emit(this.value); } } diff --git a/src/lib/inputs/dynamic-input/dynamic-input.component.html b/src/lib/inputs/dynamic-input/dynamic-input.component.html index c9c45b5..e70597e 100644 --- a/src/lib/inputs/dynamic-input/dynamic-input.component.html +++ b/src/lib/inputs/dynamic-input/dynamic-input.component.html @@ -1,43 +1,46 @@ -
- +
+ @if (label()) { + + } - + @if (isDate()) { + + - + } - + @if (isText()) { + + } - + @if (isNumber()) { + + }
diff --git a/src/lib/inputs/dynamic-input/dynamic-input.component.ts b/src/lib/inputs/dynamic-input/dynamic-input.component.ts index 9521fcb..1fa7764 100644 --- a/src/lib/inputs/dynamic-input/dynamic-input.component.ts +++ b/src/lib/inputs/dynamic-input/dynamic-input.component.ts @@ -1,13 +1,13 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { NgClass } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core'; import { FormsModule, NG_VALIDATORS, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { FormFieldComponent } from '../form-field/form-field-component.directive'; -import { NgClass, NgIf } from '@angular/common'; import { MatDatepickerModule } from '@angular/material/datepicker'; -import { StopPropagationDirective } from '../../directives'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; +import { StopPropagationDirective } from '../../directives'; +import { FormFieldComponent } from '../form-field/form-field-component.directive'; -const InputTypes = { +export const InputTypes = { DATE: 'DATE', NUMBER: 'NUMBER', TEXT: 'TEXT', @@ -19,11 +19,10 @@ export type InputType = keyof typeof InputTypes; type DynamicInput = number | string | Date; @Component({ - selector: 'iqser-dynamic-input [type]', + selector: 'iqser-dynamic-input', templateUrl: './dynamic-input.component.html', styleUrls: ['./dynamic-input.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, providers: [ { provide: NG_VALUE_ACCESSOR, @@ -36,33 +35,23 @@ type DynamicInput = number | string | Date; useExisting: DynamicInputComponent, }, ], - imports: [NgClass, NgIf, FormsModule, MatDatepickerModule, StopPropagationDirective, MatIconModule, MatInputModule], + imports: [NgClass, FormsModule, MatDatepickerModule, StopPropagationDirective, MatIconModule, MatInputModule], }) export class DynamicInputComponent extends FormFieldComponent { - @Input() label?: string; - @Input() type!: InputType; - @Input() placeholder?: string; - @Input() id?: string; - @Input() name?: string; - @Input() classList?: string = ''; - @Input() input!: DynamicInput; - @Input() canEditInput = true; - @Output() readonly closedDatepicker = new EventEmitter(); + readonly label = input(); + readonly type = input.required(); + readonly placeholder = input(); + readonly id = input(); + readonly classList = input(''); + readonly input = model(); + readonly closedDatepicker = output(); - get isDate() { - return this.type === InputTypes.DATE; - } + readonly isDate = computed(() => this.type() === InputTypes.DATE); + readonly isNumber = computed(() => this.type() === InputTypes.NUMBER); + readonly isText = computed(() => this.type() === InputTypes.TEXT); - get isNumber() { - return this.type === InputTypes.NUMBER; - } - - get isText() { - return this.type === InputTypes.TEXT; - } - - writeValue(input: DynamicInput): void { - this.input = input; + override writeValue(input: DynamicInput): void { + this.input.set(input); } onCloseDatepicker() { diff --git a/src/lib/inputs/editable-input/editable-input.component.html b/src/lib/inputs/editable-input/editable-input.component.html index 1cb24cb..0b35ec8 100644 --- a/src/lib/inputs/editable-input/editable-input.component.html +++ b/src/lib/inputs/editable-input/editable-input.component.html @@ -1,54 +1,54 @@ - -
- {{ value }} -
+@if (!_editing()) { + @if (showPreview()) { +
{{ value() }}
+ }
- + @if (canEdit()) { + + }
-
- - +} @else {
-
- - +
+ @if (!parentId()) { + + } @else { - + }
- +
- +} diff --git a/src/lib/inputs/editable-input/editable-input.component.scss b/src/lib/inputs/editable-input/editable-input.component.scss index 3e7a471..dd6e41a 100644 --- a/src/lib/inputs/editable-input/editable-input.component.scss +++ b/src/lib/inputs/editable-input/editable-input.component.scss @@ -18,3 +18,7 @@ textarea { margin: 0; min-height: 0; } + +form { + width: 100%; +} diff --git a/src/lib/inputs/editable-input/editable-input.component.ts b/src/lib/inputs/editable-input/editable-input.component.ts index bec7a5a..ee8d7cf 100644 --- a/src/lib/inputs/editable-input/editable-input.component.ts +++ b/src/lib/inputs/editable-input/editable-input.component.ts @@ -1,42 +1,40 @@ -import { NgIf } from '@angular/common'; -import { ChangeDetectionStrategy, Component, EventEmitter, HostBinding, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { booleanAttribute, ChangeDetectionStrategy, Component, input, OnChanges, output, signal, SimpleChanges } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { CircleButtonComponent, CircleButtonType, CircleButtonTypes } from '../../buttons'; +import { CircleButtonComponent } from '../../buttons/circle-button/circle-button.component'; +import { CircleButtonType, CircleButtonTypes } from '../../buttons/types/circle-button.type'; @Component({ - selector: 'iqser-editable-input [value]', + selector: 'iqser-editable-input', templateUrl: './editable-input.component.html', styleUrls: ['./editable-input.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [NgIf, CircleButtonComponent, FormsModule], + imports: [CircleButtonComponent, FormsModule], + host: { + '[class.editing]': '_editing()', + }, }) export class EditableInputComponent implements OnChanges { - @Input() id?: string; - @Input() parentId?: string; - @Input() value!: string; - @Input() editTooltip?: string; - @Input() saveTooltip?: string; - @Input() cancelTooltip?: string; - @Input() placeholder = ''; - @Input() class?: string; - @Input() showPreview = true; - @Input() canEdit = true; - @Input() buttonsType: CircleButtonType = CircleButtonTypes.default; - @Output() readonly save = new EventEmitter(); - parentDimensions?: { width: number; height: number }; + protected readonly _editing = signal(false); + readonly id = input(); + readonly parentId = input(); + readonly value = input.required(); + readonly editTooltip = input(''); + readonly saveTooltip = input(''); + readonly cancelTooltip = input(''); + readonly placeholder = input(''); + readonly class = input(); + readonly showPreview = input(true, { transform: booleanAttribute }); + readonly canEdit = input(true, { transform: booleanAttribute }); + readonly buttonsType = input(CircleButtonTypes.default); + readonly helpModeKey = input(''); + readonly lastChild = input(false, { transform: booleanAttribute }); + readonly save = output(); + textArea?: { width: number; height: number }; newValue = ''; - private _editing = false; - - @HostBinding('class.editing') - get editing(): boolean { - return this._editing; - } - set editing(value: boolean) { - this._editing = value; - this.newValue = this.value; + this._editing.set(value); + this.newValue = this.value(); } ngOnChanges(changes: SimpleChanges): void { @@ -44,13 +42,24 @@ export class EditableInputComponent implements OnChanges { this.editing = false; } if (changes['parentId']?.currentValue) { - setTimeout(() => { - const parent = document.getElementById(this.parentId as string) as HTMLElement; - this.parentDimensions = { width: parent.offsetWidth - 98, height: parent.offsetHeight - 16 }; - }, 20); + this.setTextAreaSize(); } } + setTextAreaSize() { + setTimeout(() => { + const element = document.getElementById(this.id() as string) as HTMLElement; + const parentElement = document.getElementById(this.parentId() as string) as HTMLElement; + const width = parentElement.offsetWidth - 98; + let height = (this.lastChild() ? parentElement.offsetHeight : element.offsetHeight) - 16; + if (this.lastChild()) { + const lastChildIndex = Number(this.id()?.split('-')[2]); + height = height - lastChildIndex * element.offsetHeight; + } + this.textArea = { width, height }; + }, 50); + } + saveValue(): void { this.save.emit(this.newValue); this.editing = false; diff --git a/src/lib/inputs/form-field/form-field-component.directive.ts b/src/lib/inputs/form-field/form-field-component.directive.ts index db7b09e..4cc50bb 100644 --- a/src/lib/inputs/form-field/form-field-component.directive.ts +++ b/src/lib/inputs/form-field/form-field-component.directive.ts @@ -1,19 +1,47 @@ -import { ChangeDetectorRef, Directive, inject } from '@angular/core'; -import { ControlValueAccessor, ValidationErrors, Validator } from '@angular/forms'; +import { ChangeDetectorRef, Directive, inject, Injector, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + FormControl, + FormControlDirective, + FormControlName, + FormGroupDirective, + NgControl, + ValidationErrors, + Validator, +} from '@angular/forms'; @Directive() -export abstract class FormFieldComponent implements ControlValueAccessor, Validator { +export abstract class FormFieldComponent implements ControlValueAccessor, Validator, OnInit { touched = false; disabled = false; protected readonly _changeRef = inject(ChangeDetectorRef); + protected readonly _injector = inject(Injector); + protected _formControl: FormControl | undefined; protected _value: I | undefined; get value(): I | undefined { return this._value; } + ngOnInit() { + const ngControl = this._injector.get(NgControl); + + if (ngControl instanceof FormControlName) { + this._formControl = this._injector.get(FormGroupDirective).getControl(ngControl); + } else { + this._formControl = (ngControl as FormControlDirective).form as FormControl; + } + } + + hasError(errorCode: string | undefined): boolean { + if (errorCode && this._formControl) { + return this._formControl.hasError(errorCode); + } + return false; + } + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars onChange = (value?: I) => {}; diff --git a/src/lib/inputs/index.ts b/src/lib/inputs/index.ts deleted file mode 100644 index 27194c4..0000000 --- a/src/lib/inputs/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './round-checkbox/round-checkbox.component'; -export * from './editable-input/editable-input.component'; -export * from './input-with-action/input-with-action.component'; -export * from './details-radio/details-radio.component'; -export * from './details-radio/details-radio-option'; -export * from './form-field/form-field-component.directive'; -export * from './dynamic-input/dynamic-input.component'; diff --git a/src/lib/inputs/input-with-action/input-with-action.component.html b/src/lib/inputs/input-with-action/input-with-action.component.html index 135dec8..d1ea96a 100644 --- a/src/lib/inputs/input-with-action/input-with-action.component.html +++ b/src/lib/inputs/input-with-action/input-with-action.component.html @@ -1,33 +1,33 @@ -
+ - {{ hint }} + @if (hint()) { + {{ hint() }} + } - + @if (_isSearch() && !_hasContent()) { + + } - + @if (_isSearch() && _hasContent()) { + + } - + @if (icon(); as icon) { + + }
diff --git a/src/lib/inputs/input-with-action/input-with-action.component.scss b/src/lib/inputs/input-with-action/input-with-action.component.scss index f2c22fd..c09091c 100644 --- a/src/lib/inputs/input-with-action/input-with-action.component.scss +++ b/src/lib/inputs/input-with-action/input-with-action.component.scss @@ -1,5 +1,5 @@ :host { - display: block; + display: contents; } mat-icon.disabled { @@ -9,6 +9,7 @@ mat-icon.disabled { iqser-circle-button { position: absolute; - top: 4px; - right: 5px; + --circle-button-size: calc(var(--iqser-inputs-height) * 0.7) !important; + top: calc((var(--iqser-inputs-height) - var(--circle-button-size)) / 2 - 1px); + right: calc((var(--iqser-inputs-height) - var(--circle-button-size)) / 2 - 1px); } diff --git a/src/lib/inputs/input-with-action/input-with-action.component.ts b/src/lib/inputs/input-with-action/input-with-action.component.ts index cd58098..909cb52 100644 --- a/src/lib/inputs/input-with-action/input-with-action.component.ts +++ b/src/lib/inputs/input-with-action/input-with-action.component.ts @@ -1,53 +1,39 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, Output } from '@angular/core'; -import { randomString } from '../../utils'; +import { booleanAttribute, ChangeDetectionStrategy, Component, computed, input, model, output } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { NgIf } from '@angular/common'; -import { CircleButtonComponent } from '../../buttons'; import { MatIconModule } from '@angular/material/icon'; +import { CircleButtonComponent } from '../../buttons/circle-button/circle-button.component'; +import { randomString } from '../../utils/functions'; + @Component({ selector: 'iqser-input-with-action', templateUrl: './input-with-action.component.html', styleUrls: ['./input-with-action.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [FormsModule, NgIf, MatIconModule, CircleButtonComponent], + imports: [FormsModule, MatIconModule, CircleButtonComponent], }) export class InputWithActionComponent { - @Input() inputId = `${randomString() + '-search-input'}`; - @Input() actionButtonId = `${randomString() + '-action-input'}`; - @Input() placeholder = ''; - @Input() hint?: string; - @Input() width: number | 'full' = 250; - @Input() icon?: string; - @Input() autocomplete: 'on' | 'off' = 'on'; - @Input() value = ''; - @Output() readonly action = new EventEmitter(); - @Output() readonly valueChange = new EventEmitter(); + readonly inputId = input(`${randomString() + '-search-input'}`); + readonly actionButtonId = input(`${randomString() + '-action-input'}`); + readonly placeholder = input(''); + readonly hint = input(); + readonly width = input(250); + protected readonly _computedWidth = computed(() => (this.width() === 'full' ? '100%' : `${this.width()}px`)); + readonly icon = input(); + protected readonly _isSearch = computed(() => !this.icon()); + readonly autocomplete = input<'on' | 'off'>('on'); + readonly value = model(''); + protected readonly _hasContent = computed(() => !!this.value()?.length); + readonly disabled = input(false, { transform: booleanAttribute }); + readonly action = output(); - get hasContent(): boolean { - return !!this.value?.length; + reset() { + this.value.set(''); } - get computedWidth(): string { - return this.width === 'full' ? '100%' : `${this.width}px`; - } - - get isSearch(): boolean { - return !this.icon; - } - - constructor(private readonly _changeDetectorRef: ChangeDetectorRef) {} - - reset(): void { - this.value = ''; - this.valueChange.emit(this.value); - this._changeDetectorRef.markForCheck(); - } - - executeAction(): void { - if (this.hasContent) { - this.action.emit(this.value); + executeAction() { + if (this._hasContent()) { + this.action.emit(this.value()); } } } diff --git a/src/lib/inputs/round-checkbox/round-checkbox.component.html b/src/lib/inputs/round-checkbox/round-checkbox.component.html index 39efd44..189049e 100644 --- a/src/lib/inputs/round-checkbox/round-checkbox.component.html +++ b/src/lib/inputs/round-checkbox/round-checkbox.component.html @@ -1,11 +1,16 @@
- - + @if (active() && !indeterminate()) { + + } + + @if (indeterminate()) { + + }
diff --git a/src/lib/inputs/round-checkbox/round-checkbox.component.ts b/src/lib/inputs/round-checkbox/round-checkbox.component.ts index 8001bdc..0390788 100644 --- a/src/lib/inputs/round-checkbox/round-checkbox.component.ts +++ b/src/lib/inputs/round-checkbox/round-checkbox.component.ts @@ -1,27 +1,24 @@ -import { ChangeDetectionStrategy, Component, ElementRef, HostBinding, Input, OnInit, ViewChild } from '@angular/core'; +import { booleanAttribute, ChangeDetectionStrategy, Component, effect, ElementRef, input, numberAttribute, viewChild } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; -import { NgIf } from '@angular/common'; @Component({ selector: 'iqser-round-checkbox', templateUrl: './round-checkbox.component.html', styleUrls: ['./round-checkbox.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [MatIconModule, NgIf], + imports: [MatIconModule], }) -export class RoundCheckboxComponent implements OnInit { - @Input() size = 20; - @Input() active = false; - @Input() indeterminate = false; - @Input() type: 'default' | 'with-bg' = 'default'; - @HostBinding('class.disabled') - @Input() - disabled = false; +export class RoundCheckboxComponent { + protected readonly _wrapper = viewChild.required('wrapper', { read: ElementRef }); + readonly size = input(20, { transform: numberAttribute }); + readonly active = input(false, { transform: booleanAttribute }); + readonly indeterminate = input(false, { transform: booleanAttribute }); + readonly type = input<'default' | 'with-bg'>('default'); + readonly disabled = input(false, { transform: booleanAttribute }); - @ViewChild('wrapper', { static: true }) private readonly _wrapper!: ElementRef; - - ngOnInit(): void { - (this._wrapper.nativeElement as HTMLElement).style.setProperty('--size', `${this.size}px`); + constructor() { + effect(() => { + (this._wrapper().nativeElement as HTMLElement).style.setProperty('--size', `${this.size()}px`); + }); } } diff --git a/src/lib/listing/listing-component.directive.ts b/src/lib/listing/listing-component.directive.ts index 327019d..8aea765 100644 --- a/src/lib/listing/listing-component.directive.ts +++ b/src/lib/listing/listing-component.directive.ts @@ -1,12 +1,16 @@ import { Directive, inject, OnDestroy, TemplateRef, ViewChild } from '@angular/core'; import { combineLatest, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { FilterService } from '../filtering'; -import { SortingService } from '../sorting'; -import { AutoUnsubscribe, shareDistinctLast } from '../utils'; -import { SearchService } from '../search'; -import { EntitiesService, ListingService } from './services'; -import { Id, IListable, TableColumnConfig } from './models'; +import { FilterService } from '../filtering/filter.service'; +import { SearchService } from '../search/search.service'; +import { SortingService } from '../sorting/sorting.service'; +import { AutoUnsubscribe } from '../utils/auto-unsubscribe.directive'; +import { shareDistinctLast } from '../utils/operators'; +import { IListable } from './models/listable'; +import { TableColumnConfig } from './models/table-column-config.model'; +import { Id } from './models/trackable'; +import { EntitiesService } from './services/entities.service'; +import { ListingService } from './services/listing.service'; @Directive() export abstract class ListingComponent, PrimaryKey extends Id = Class['id']> diff --git a/src/lib/listing/listing.module.ts b/src/lib/listing/listing.module.ts index 575ac93..cf3608e 100644 --- a/src/lib/listing/listing.module.ts +++ b/src/lib/listing/listing.module.ts @@ -1,49 +1,56 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { TranslateModule } from '@ngx-translate/core'; -import { TableHeaderComponent } from './table-header/table-header.component'; -import { IqserFiltersModule } from '../filtering'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { TableColumnNameComponent } from './table-column-name/table-column-name.component'; -import { ScrollButtonComponent } from './scroll-button/scroll-button.component'; -import { TableComponent } from './table/table.component'; -import { HasScrollbarDirective, SyncWidthDirective } from '../directives'; -import { ScrollingModule } from '@angular/cdk/scrolling'; -import { RouterModule } from '@angular/router'; -import { WorkflowComponent } from './workflow/workflow.component'; import { DragDropModule } from '@angular/cdk/drag-drop'; +import { ScrollingModule } from '@angular/cdk/scrolling'; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { RouterModule } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { CircleButtonComponent, IconButtonComponent } from '../buttons'; +import { HasScrollbarDirective, SyncWidthDirective } from '../directives'; +import { EmptyStateComponent } from '../empty-state'; +import { IqserFiltersModule, PopupFilterComponent } from '../filtering'; +import { InputWithActionComponent } from '../inputs/input-with-action/input-with-action.component'; +import { RoundCheckboxComponent } from '../inputs/round-checkbox/round-checkbox.component'; +import { SnakeCasePipe } from '../pipes/snake-case.pipe'; import { PageHeaderComponent } from './page-header/page-header.component'; -import { IqserHelpModeModule } from '../help-mode'; +import { ScrollButtonComponent } from './scroll-button/scroll-button.component'; +import { TableColumnNameComponent } from './table-column-name/table-column-name.component'; import { TableContentComponent } from './table-content/table-content.component'; import { TableItemComponent } from './table-content/table-item/table-item.component'; +import { TableHeaderComponent } from './table-header/table-header.component'; +import { TableComponent } from './table/table.component'; import { ColumnHeaderComponent } from './workflow/column-header/column-header.component'; -import { CircleButtonComponent, IconButtonComponent } from '../buttons'; -import { MatIconModule } from '@angular/material/icon'; -import { EmptyStateComponent } from '../empty-state'; -import { InputWithActionComponent, RoundCheckboxComponent } from '../inputs'; -import { TenantPipe } from '../tenants/tenant.pipe'; +import { WorkflowComponent } from './workflow/workflow.component'; const matModules = [MatTooltipModule, MatIconModule]; -const components = [ - TableHeaderComponent, - TableComponent, - WorkflowComponent, - TableColumnNameComponent, - ScrollButtonComponent, - PageHeaderComponent, - TableContentComponent, - TableItemComponent, - ColumnHeaderComponent, -]; -const modules = [DragDropModule, TranslateModule, IqserFiltersModule, ScrollingModule, RouterModule, IqserHelpModeModule]; +const modules = [DragDropModule, TranslateModule, IqserFiltersModule, ScrollingModule, RouterModule]; @NgModule({ - declarations: [...components], - exports: [...components], + exports: [ + WorkflowComponent, + ScrollButtonComponent, + PageHeaderComponent, + TableContentComponent, + TableColumnNameComponent, + TableHeaderComponent, + TableComponent, + ColumnHeaderComponent, + TableItemComponent, + ], imports: [ CommonModule, ...modules, ...matModules, + WorkflowComponent, + ScrollButtonComponent, + PageHeaderComponent, + TableColumnNameComponent, + TableContentComponent, + TableComponent, + TableItemComponent, + ColumnHeaderComponent, + TableHeaderComponent, CircleButtonComponent, IconButtonComponent, EmptyStateComponent, @@ -51,7 +58,8 @@ const modules = [DragDropModule, TranslateModule, IqserFiltersModule, ScrollingM RoundCheckboxComponent, InputWithActionComponent, SyncWidthDirective, - TenantPipe, + SnakeCasePipe, + PopupFilterComponent, ], }) export class IqserListingModule {} diff --git a/src/lib/listing/page-header/models/action-config.model.ts b/src/lib/listing/page-header/models/action-config.model.ts index dbfb226..c142397 100644 --- a/src/lib/listing/page-header/models/action-config.model.ts +++ b/src/lib/listing/page-header/models/action-config.model.ts @@ -1,9 +1,10 @@ import { BaseHeaderConfig } from './base-config.model'; import { Observable } from 'rxjs'; -import { OverlappingElement } from '../../../help-mode'; export interface ActionConfig extends BaseHeaderConfig { readonly action: ($event: MouseEvent) => void; readonly helpModeKey?: string; readonly disabled$?: Observable; + readonly disabled?: boolean; + readonly disableStopPropagation?: boolean; } diff --git a/src/lib/listing/page-header/models/button-config.model.ts b/src/lib/listing/page-header/models/button-config.model.ts index 67ab4d8..976d1f2 100644 --- a/src/lib/listing/page-header/models/button-config.model.ts +++ b/src/lib/listing/page-header/models/button-config.model.ts @@ -3,4 +3,5 @@ import { ActionConfig } from './action-config.model'; export interface ButtonConfig extends ActionConfig { readonly type?: IconButtonType; + readonly tooltip?: string; } diff --git a/src/lib/listing/page-header/page-header.component.html b/src/lib/listing/page-header/page-header.component.html index 4952155..45e5fd7 100644 --- a/src/lib/listing/page-header/page-header.component.html +++ b/src/lib/listing/page-header/page-header.component.html @@ -1,94 +1,102 @@ diff --git a/src/lib/listing/table-content/table-content.component.scss b/src/lib/listing/table-content/table-content.component.scss index c75e0e5..1abf3b3 100644 --- a/src/lib/listing/table-content/table-content.component.scss +++ b/src/lib/listing/table-content/table-content.component.scss @@ -2,7 +2,7 @@ :host cdk-virtual-scroll-viewport { height: calc(100vh - 50px - 31px - var(--iqser-top-bar-height) - 50px); - overflow-y: hidden !important; + overflow-y: auto !important; background-color: var(--iqser-background); @include mixins.scroll-bar; @@ -10,10 +10,6 @@ display: none; } - &.has-scrollbar:hover ::ng-deep.cdk-virtual-scroll-content-wrapper { - grid-template-columns: var(--gridTemplateColumnsHover); - } - ::ng-deep.cdk-virtual-scroll-content-wrapper { grid-template-columns: var(--gridTemplateColumns); display: grid; @@ -37,6 +33,7 @@ } &:hover, + &:has(iqser-circle-button[aria-expanded='true']), &.help-mode-active { .selection-column iqser-round-checkbox .wrapper { opacity: 1; @@ -52,6 +49,10 @@ > * > div { background-color: var(--iqser-not-disabled-table-item); } + + .scrollbar-placeholder { + background-color: var(--iqser-side-nav); + } } } } @@ -64,10 +65,6 @@ right: 0; padding-right: 10px; } - - .scrollbar-placeholder { - display: none !important; - } } } } @@ -75,3 +72,8 @@ .display-contents { display: contents; } + +a { + display: contents; + @include mixins.clear-a; +} diff --git a/src/lib/listing/table-content/table-content.component.ts b/src/lib/listing/table-content/table-content.component.ts index bd400bf..8f0572d 100644 --- a/src/lib/listing/table-content/table-content.component.ts +++ b/src/lib/listing/table-content/table-content.component.ts @@ -1,36 +1,54 @@ /* eslint-disable @angular-eslint/prefer-on-push-component-change-detection */ +import { CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; +import { AsyncPipe, NgClass } from '@angular/common'; import { AfterViewInit, Component, forwardRef, HostListener, Inject, Input, OnDestroy, Optional, ViewChild } from '@angular/core'; -import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; -import { delay, tap } from 'rxjs/operators'; -import { AutoUnsubscribe, trackByFactory } from '../../utils'; -import { Id, IListable } from '../models'; -import { ListingComponent, ListingService } from '../index'; +import { RouterLink } from '@angular/router'; import { BehaviorSubject } from 'rxjs'; -import { HelpModeService } from '../../help-mode'; -import { HasScrollbarDirective } from '../../directives'; +import { delay, tap } from 'rxjs/operators'; +import { HasScrollbarDirective } from '../../directives/has-scrollbar.directive'; +import { HelpModeService } from '../../help-mode/help-mode.service'; +import { SnakeCasePipe } from '../../pipes/snake-case.pipe'; +import { AutoUnsubscribe } from '../../utils/auto-unsubscribe.directive'; +import { trackByFactory } from '../../utils/functions'; +import { ListingComponent } from '../listing-component.directive'; +import { IListable } from '../models/listable'; +import { Id } from '../models/trackable'; +import { ListingService } from '../services/listing.service'; +import { TableItemComponent } from './table-item/table-item.component'; @Component({ selector: 'iqser-table-content', templateUrl: './table-content.component.html', styleUrls: ['./table-content.component.scss'], + imports: [ + CdkVirtualScrollViewport, + AsyncPipe, + CdkFixedSizeVirtualScroll, + HasScrollbarDirective, + CdkVirtualForOf, + SnakeCasePipe, + NgClass, + RouterLink, + TableItemComponent, + ], }) export class TableContentComponent, PrimaryKey extends Id = Class['id']> extends AutoUnsubscribe implements OnDestroy, AfterViewInit { + private _lastScrolledIndex = 0; + private _multiSelectActive$ = new BehaviorSubject(false); @Input() itemSize!: number; @Input() itemMouseEnterFn?: (entity: Class) => void; @Input() itemMouseLeaveFn?: (entity: Class) => void; @Input() tableItemClasses?: Record boolean>; @Input() selectionEnabled!: boolean; + @Input() rowIdPrefix: string = 'item'; + @Input() namePropertyKey?: string; readonly trackBy = trackByFactory(); - @ViewChild(CdkVirtualScrollViewport, { static: true }) readonly scrollViewport!: CdkVirtualScrollViewport; @ViewChild(HasScrollbarDirective, { static: true }) readonly hasScrollbarDirective!: HasScrollbarDirective; - private _lastScrolledIndex = 0; - private _multiSelectActive$ = new BehaviorSubject(false); - constructor( @Inject(forwardRef(() => ListingComponent)) readonly listingComponent: ListingComponent, readonly listingService: ListingService, @@ -68,7 +86,7 @@ export class TableContentComponent, PrimaryK getTableItemClasses(entity: Class): Record { const classes: Record = { 'table-item': true, - pointer: !!entity.routerLink && entity.routerLink.length > 0, + 'cursor-default': !entity.routerLink, }; for (const key in this.tableItemClasses) { if (Object.prototype.hasOwnProperty.call(this.tableItemClasses, key)) { diff --git a/src/lib/listing/table-content/table-item/table-item.component.html b/src/lib/listing/table-content/table-item/table-item.component.html index 62a632b..8107390 100644 --- a/src/lib/listing/table-content/table-item/table-item.component.html +++ b/src/lib/listing/table-content/table-item/table-item.component.html @@ -1,6 +1,8 @@ -
- -
+@if (selectionEnabled) { +
+ +
+} diff --git a/src/lib/listing/table-content/table-item/table-item.component.ts b/src/lib/listing/table-content/table-item/table-item.component.ts index 5ab456b..5c90093 100644 --- a/src/lib/listing/table-content/table-item/table-item.component.ts +++ b/src/lib/listing/table-content/table-item/table-item.component.ts @@ -1,22 +1,24 @@ +import { AsyncPipe, NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, Component, forwardRef, Inject, Input, OnChanges } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; -import { ListingComponent } from '../..'; -import { IListable } from '../../models'; -import { ListingService } from '../../services'; import { switchMap } from 'rxjs/operators'; +import { RoundCheckboxComponent } from '../../../inputs/round-checkbox/round-checkbox.component'; +import { ListingComponent } from '../../listing-component.directive'; +import { IListable } from '../../models/listable'; +import { ListingService } from '../../services/listing.service'; @Component({ selector: 'iqser-table-item [entity]', templateUrl: './table-item.component.html', styleUrls: ['./table-item.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RoundCheckboxComponent, AsyncPipe, NgTemplateOutlet], }) export class TableItemComponent implements OnChanges { @Input() entity!: T; - @Input() selectionEnabled = false; - - readonly isSelected$: Observable; readonly #entityChanged$ = new BehaviorSubject(this.entity); + @Input() selectionEnabled = false; + readonly isSelected$: Observable; constructor( @Inject(forwardRef(() => ListingComponent)) readonly listingComponent: ListingComponent, @@ -31,6 +33,7 @@ export class TableItemComponent implements OnChanges { toggleEntitySelected($event: MouseEvent, entity: T): void { $event.stopPropagation(); + $event.preventDefault(); this.listingService.select(entity, $event.shiftKey); } } diff --git a/src/lib/listing/table-header/table-header.component.html b/src/lib/listing/table-header/table-header.component.html index 527378c..02dea44 100644 --- a/src/lib/listing/table-header/table-header.component.html +++ b/src/lib/listing/table-header/table-header.component.html @@ -1,47 +1,56 @@
-
- +
+ @if (selectionEnabled) { + + } - + {{ tableHeaderLabel | translate: { length: totalSize || (listingService.displayedLength$ | async) } }} + @if (listingService.selectedLength$ | async; as selectedItems) { + ({{ 'table-header.selected-count' | translate: { count: selectedItems } }}) + }
- + @if (quickFilters$ | async) { + + }
-
-
- - - -
- -
-
+@if (listingMode === listingModes.table) { +
+ @if (selectionEnabled) { +
+ } + @for (config of tableColumnConfigs; track config) { + + } + @if (hasEmptyColumn) { +
+ } +
+
+} diff --git a/src/lib/listing/table-header/table-header.component.ts b/src/lib/listing/table-header/table-header.component.ts index 7c0212b..0bd601a 100644 --- a/src/lib/listing/table-header/table-header.component.ts +++ b/src/lib/listing/table-header/table-header.component.ts @@ -1,13 +1,27 @@ -import { ChangeDetectionStrategy, Component, Input, TemplateRef } from '@angular/core'; -import { FilterService } from '../../filtering'; -import { EntitiesService, ListingService } from '../services'; +import { AsyncPipe, NgTemplateOutlet } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject, Input, TemplateRef } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { SyncWidthDirective } from '../../directives'; +import { FilterService, IqserFiltersModule } from '../../filtering'; +import { RoundCheckboxComponent } from '../../inputs/round-checkbox/round-checkbox.component'; import { Id, IListable, ListingMode, ListingModes, TableColumnConfig } from '../models'; +import { EntitiesService, ListingService } from '../services'; +import { TableColumnNameComponent } from '../table-column-name/table-column-name.component'; @Component({ selector: 'iqser-table-header [tableHeaderLabel]', templateUrl: './table-header.component.html', styleUrls: ['./table-header.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + RoundCheckboxComponent, + AsyncPipe, + TranslateModule, + NgTemplateOutlet, + IqserFiltersModule, + SyncWidthDirective, + TableColumnNameComponent, + ], }) export class TableHeaderComponent, PrimaryKey extends Id = T['id']> { readonly listingModes = ListingModes; @@ -21,11 +35,10 @@ export class TableHeaderComponent, PrimaryKey ex @Input() bulkActions?: TemplateRef; @Input() helpModeKey?: string; - readonly quickFilters$ = this.filterService.getFilterModels$('quickFilters'); + readonly quickFilters$ = inject(FilterService).getFilterModels$('quickFilters'); constructor( readonly entitiesService: EntitiesService, readonly listingService: ListingService, - readonly filterService: FilterService, ) {} } diff --git a/src/lib/listing/table/table.component.html b/src/lib/listing/table/table.component.html index 4d430a1..6327b1a 100644 --- a/src/lib/listing/table/table.component.html +++ b/src/lib/listing/table/table.component.html @@ -1,40 +1,46 @@ - +@if (entitiesService.noData$ | async) { + +} - +@if (listingComponent.noMatch$ | async) { + +} - +@if (hasScrollButton && tableContent?.scrollViewport) { + +} diff --git a/src/lib/listing/table/table.component.ts b/src/lib/listing/table/table.component.ts index 384015a..da3da76 100644 --- a/src/lib/listing/table/table.component.ts +++ b/src/lib/listing/table/table.component.ts @@ -1,3 +1,4 @@ +import { AsyncPipe, NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -13,10 +14,13 @@ import { ViewChild, ViewContainerRef, } from '@angular/core'; -import { Id, IListable, ListingModes, TableColumnConfig } from '../models'; +import { EmptyStateComponent } from '../../empty-state'; import { ListingComponent } from '../listing-component.directive'; +import { Id, IListable, ListingModes, TableColumnConfig } from '../models'; +import { ScrollButtonComponent } from '../scroll-button/scroll-button.component'; import { EntitiesService } from '../services'; import { TableContentComponent } from '../table-content/table-content.component'; +import { TableHeaderComponent } from '../table-header/table-header.component'; const SCROLLBAR_WIDTH = 11; @@ -24,10 +28,11 @@ const SCROLLBAR_WIDTH = 11; selector: 'iqser-table [tableColumnConfigs] [itemSize]', templateUrl: './table.component.html', changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TableHeaderComponent, NgTemplateOutlet, AsyncPipe, EmptyStateComponent, ScrollButtonComponent, TableContentComponent], }) export class TableComponent, PrimaryKey extends Id = Class['id']> implements OnChanges { + @ViewChild(TableContentComponent, { static: true }) private readonly _tableContent!: TableContentComponent; readonly listingModes = ListingModes; - @Input() tableColumnConfigs!: readonly TableColumnConfig[]; @Input() bulkActions?: TemplateRef; @Input() headerTemplate?: TemplateRef; @@ -46,10 +51,11 @@ export class TableComponent, PrimaryKey exte @Input() helpModeKey?: string; @Input() headerHelpModeKey?: string; @Input() tableItemClasses?: Record boolean>; + @Input() rowIdPrefix: string = 'item'; + @Input() namePropertyKey?: string; @Input() itemMouseEnterFn?: (entity: Class) => void; @Input() itemMouseLeaveFn?: (entity: Class) => void; @Output() readonly noDataAction = new EventEmitter(); - @ViewChild(TableContentComponent, { static: true }) private readonly _tableContent!: TableContentComponent; constructor( @Inject(forwardRef(() => ListingComponent)) readonly listingComponent: ListingComponent, @@ -80,18 +86,17 @@ export class TableComponent, PrimaryKey exte } private _setColumnsWidth(element: HTMLElement) { - let gridTemplateColumnsHover = ''; + let gridTemplateColumns = ''; if (this.selectionEnabled) { - gridTemplateColumnsHover += '30px '; + gridTemplateColumns += '30px '; } for (const config of this.tableColumnConfigs) { - gridTemplateColumnsHover += `${config.width || '1fr'} `; + gridTemplateColumns += `${config.width || '1fr'} `; } - gridTemplateColumnsHover += this.emptyColumnWidth || ''; - const gridTemplateColumns = `${gridTemplateColumnsHover} ${SCROLLBAR_WIDTH}px`; + gridTemplateColumns += this.emptyColumnWidth || ''; + gridTemplateColumns = `${gridTemplateColumns} ${SCROLLBAR_WIDTH}px`; element.style.setProperty('--gridTemplateColumns', gridTemplateColumns); - element.style.setProperty('--gridTemplateColumnsHover', gridTemplateColumnsHover); } private _setItemSize(element: HTMLElement) { diff --git a/src/lib/listing/workflow/column-header/column-header.component.html b/src/lib/listing/workflow/column-header/column-header.component.html index 85af253..ca89188 100644 --- a/src/lib/listing/workflow/column-header/column-header.component.html +++ b/src/lib/listing/workflow/column-header/column-header.component.html @@ -1,43 +1,46 @@ - - +@if (componentContext$ | async; as ctx) { + @if (ctx.entities; as entities) {
{{ column.label | translate }} ({{ entities.length || 0 }}) - -
- - -
+ @if (!activeSelection && !selectionColumn && entities.length > 1) { + + } + @if (activeSelection) { +
+ + +
+ }
-
-
- - - + @if (activeSelection) { +
+
+ + +
+
+ @if (bulkActionsContainerWidth) { + + } +
+
- -
- -
- - -
- - + } + } +} diff --git a/src/lib/listing/workflow/column-header/column-header.component.ts b/src/lib/listing/workflow/column-header/column-header.component.ts index 6320a4e..a18c8d3 100644 --- a/src/lib/listing/workflow/column-header/column-header.component.ts +++ b/src/lib/listing/workflow/column-header/column-header.component.ts @@ -1,3 +1,4 @@ +import { AsyncPipe, NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -10,12 +11,16 @@ import { TemplateRef, ViewChild, } from '@angular/core'; -import { combineLatest, Observable } from 'rxjs'; +import { TranslateModule } from '@ngx-translate/core'; +import { combineLatest } from 'rxjs'; import { filter, map, tap } from 'rxjs/operators'; -import { CircleButtonTypes } from '../../../buttons'; -import { IListable } from '../../models'; -import { Debounce, ContextComponent } from '../../../utils'; -import { ListingService } from '../../services'; +import { CircleButtonComponent } from '../../../buttons/circle-button/circle-button.component'; +import { CircleButtonTypes } from '../../../buttons/types/circle-button.type'; +import { RoundCheckboxComponent } from '../../../inputs/round-checkbox/round-checkbox.component'; +import { ContextComponent } from '../../../utils/context.component'; +import { Debounce } from '../../../utils/decorators/debounce.decorator'; +import { IListable } from '../../models/listable'; +import { ListingService } from '../../services/listing.service'; import { WorkflowColumn } from '../models/workflow-column.model'; interface ColumnHeaderContext { @@ -31,6 +36,7 @@ interface ColumnHeaderContext { templateUrl: './column-header.component.html', styleUrls: ['./column-header.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + imports: [AsyncPipe, TranslateModule, RoundCheckboxComponent, NgTemplateOutlet, CircleButtonComponent], }) export class ColumnHeaderComponent extends ContextComponent implements OnInit { readonly circleButtonTypes = CircleButtonTypes; diff --git a/src/lib/listing/workflow/workflow.component.html b/src/lib/listing/workflow/workflow.component.html index 203f3b0..c43ee82 100644 --- a/src/lib/listing/workflow/workflow.component.html +++ b/src/lib/listing/workflow/workflow.component.html @@ -1,69 +1,78 @@ - +@if (componentContext$ | async; as ctx) { - - - -
-
- -
+ @if (ctx.noData) { + + } + @if (!ctx.noData) { +
+ @for (column of config.columns; track column) {
- - - - - -
-
- - - -
- -
-
-
+ + @if (column.entities | async; as entities) { +
+ @for (entity of entities; track trackBy($index, entity)) { +
+ @if (!ctx.draggingEntities.includes(entity)) { + + } + + @for (e of ctx.draggingEntities; track e) { +
+ } +
+ + @for (e of ctx.draggingEntities; track e) { +
+ +
+ } +
+
+ } + @if (column.key === addElementColumn) { +
+ +
+ } +
+ }
- -
- -
-
+ }
-
- + } +} diff --git a/src/lib/listing/workflow/workflow.component.scss b/src/lib/listing/workflow/workflow.component.scss index 7c47447..9f671d9 100644 --- a/src/lib/listing/workflow/workflow.component.scss +++ b/src/lib/listing/workflow/workflow.component.scss @@ -1,5 +1,5 @@ -@import '../../../assets/styles/common-variables'; -@import '../../../assets/styles/common-mixins'; +@use '../../../assets/styles/common-variables'; +@use '../../../assets/styles/common-mixins' as mixins; :host { display: flex; @@ -67,7 +67,7 @@ .cdk-drop-list { overflow-y: auto; - @include no-scroll-bar; + @include mixins.no-scroll-bar; min-height: calc(100% - 36px); &.multi-select-active { @@ -135,5 +135,5 @@ cdk-virtual-scroll-viewport { height: 100%; - @include no-scroll-bar; + @include mixins.no-scroll-bar; } diff --git a/src/lib/listing/workflow/workflow.component.ts b/src/lib/listing/workflow/workflow.component.ts index c3f1d5e..10c2595 100644 --- a/src/lib/listing/workflow/workflow.component.ts +++ b/src/lib/listing/workflow/workflow.component.ts @@ -1,3 +1,13 @@ +import { + CdkDrag, + CdkDragDrop, + CdkDragPlaceholder, + CdkDragPreview, + CdkDragStart, + CdkDropList, + CdkDropListGroup, +} from '@angular/cdk/drag-drop'; +import { AsyncPipe, NgClass, NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -14,16 +24,19 @@ import { TemplateRef, ViewChildren, } from '@angular/core'; -import { ListingComponent } from '../listing-component.directive'; -import { CdkDragDrop, CdkDragStart, CdkDropList } from '@angular/cdk/drag-drop'; -import { ContextComponent, Debounce, trackByFactory } from '../../utils'; -import { IListable } from '../models'; -import { EntitiesService, ListingService } from '../services'; +import { MatIcon } from '@angular/material/icon'; import { BehaviorSubject } from 'rxjs'; import { filter, tap } from 'rxjs/operators'; -import { WorkflowConfig } from './models/workflow-config.model'; -import { WorkflowColumn } from './models/workflow-column.model'; +import { EmptyStateComponent } from '../../empty-state'; +import { ContextComponent, Debounce, trackByFactory } from '../../utils'; +import { ListingComponent } from '../listing-component.directive'; +import { IListable } from '../models'; +import { EntitiesService, ListingService } from '../services'; +import { TableHeaderComponent } from '../table-header/table-header.component'; +import { ColumnHeaderComponent } from './column-header/column-header.component'; import { EntityWrapper } from './models/entity-wrapper.model'; +import { WorkflowColumn } from './models/workflow-column.model'; +import { WorkflowConfig } from './models/workflow-config.model'; interface WorkflowContext { noData: boolean; @@ -37,8 +50,26 @@ interface WorkflowContext { templateUrl: './workflow.component.html', styleUrls: ['./workflow.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + TableHeaderComponent, + AsyncPipe, + EmptyStateComponent, + CdkDropListGroup, + ColumnHeaderComponent, + CdkDropList, + CdkDrag, + NgClass, + CdkDragPlaceholder, + NgTemplateOutlet, + CdkDragPreview, + MatIcon, + ], }) export class WorkflowComponent extends ContextComponent> implements OnInit { + @ViewChildren(CdkDropList) private readonly _dropLists!: QueryList; + private readonly _observer = new ResizeObserver((entries: ResizeObserverEntry[]) => { + this._updateItemSize(entries[0]); + }); @Input() config!: WorkflowConfig; @Input() itemClasses!: Record boolean>; @Input() addElementIcon!: string; @@ -51,9 +82,7 @@ export class WorkflowComponent extends Co @Input() bulkActions?: TemplateRef; @Output() readonly noDataAction = new EventEmitter(); @Output() readonly addElement = new EventEmitter(); - @ContentChild('workflowItemTemplate') readonly itemTemplate!: TemplateRef<{ entity: T }>; - readonly trackBy = trackByFactory(); itemHeight?: number; itemWidth?: number; @@ -62,10 +91,6 @@ export class WorkflowComponent extends Co selectionColumn?: WorkflowColumn; readonly draggingEntities$ = new BehaviorSubject([]); all: { [key: string]: EntityWrapper } = {}; - @ViewChildren(CdkDropList) private readonly _dropLists!: QueryList; - private readonly _observer = new ResizeObserver((entries: ResizeObserverEntry[]) => { - this._updateItemSize(entries[0]); - }); constructor( @Inject(forwardRef(() => ListingComponent)) readonly listingComponent: ListingComponent, @@ -236,7 +261,11 @@ export class WorkflowComponent extends Co private _shouldUpdate(entity: T): boolean { const existingEntity = this.all[entity.id]?.entity; - return existingEntity && this.config.itemVersionFn(entity) !== this.config.itemVersionFn(existingEntity); + return ( + existingEntity && + (this.config.itemVersionFn(entity) !== this.config.itemVersionFn(existingEntity) || + JSON.stringify(entity) !== JSON.stringify(existingEntity)) + ); } private _shouldAdd(entity: T): boolean { diff --git a/src/lib/loading/full-page-loading-indicator/full-page-loading-indicator.component.html b/src/lib/loading/full-page-loading-indicator/full-page-loading-indicator.component.html index b451ba8..cf1bbfb 100644 --- a/src/lib/loading/full-page-loading-indicator/full-page-loading-indicator.component.html +++ b/src/lib/loading/full-page-loading-indicator/full-page-loading-indicator.component.html @@ -1,12 +1,12 @@ - +@if (loadingService.isLoading(); as config) {
- - - + @if (config.type === 'spinner') { + + } + @if (config.type === 'progress-bar') { - - + }
-
+} diff --git a/src/lib/loading/full-page-loading-indicator/full-page-loading-indicator.component.ts b/src/lib/loading/full-page-loading-indicator/full-page-loading-indicator.component.ts index 8eb1469..43bca4c 100644 --- a/src/lib/loading/full-page-loading-indicator/full-page-loading-indicator.component.ts +++ b/src/lib/loading/full-page-loading-indicator/full-page-loading-indicator.component.ts @@ -5,6 +5,7 @@ import { LoadingService } from '../loading.service'; selector: 'iqser-full-page-loading-indicator', templateUrl: './full-page-loading-indicator.component.html', changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, }) export class FullPageLoadingIndicatorComponent { constructor(readonly loadingService: LoadingService) {} diff --git a/src/lib/loading/progress-bar/progress-bar.component.ts b/src/lib/loading/progress-bar/progress-bar.component.ts index 6192d9b..acd0830 100644 --- a/src/lib/loading/progress-bar/progress-bar.component.ts +++ b/src/lib/loading/progress-bar/progress-bar.component.ts @@ -1,17 +1,18 @@ -import { ChangeDetectionStrategy, Component, Input, Optional } from '@angular/core'; -import { ProgressBarConfigModel } from './progress-bar-config.model'; -import { FilterService, INestedFilter } from '../../filtering'; +import { ChangeDetectionStrategy, Component, Input, OnInit, Optional } from '@angular/core'; import { Observable, of } from 'rxjs'; -import { get, shareLast } from '../../utils'; import { map } from 'rxjs/operators'; +import { FilterService, INestedFilter } from '../../filtering'; +import { get, shareLast } from '../../utils'; +import { ProgressBarConfigModel } from './progress-bar-config.model'; @Component({ selector: 'iqser-progress-bar [config]', templateUrl: './progress-bar.component.html', styleUrls: ['./progress-bar.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, }) -export class ProgressBarComponent { +export class ProgressBarComponent implements OnInit { @Input() config!: ProgressBarConfigModel; @Input() filterKey?: string; diff --git a/src/lib/loading/progress-loading/progress-loading.component.html b/src/lib/loading/progress-loading/progress-loading.component.html index 158c70c..289ac9d 100644 --- a/src/lib/loading/progress-loading/progress-loading.component.html +++ b/src/lib/loading/progress-loading/progress-loading.component.html @@ -1,4 +1,6 @@ -

{{ config.title }}

+@if (config.title) { +

{{ config.title }}

+}
- {{ config.value }}% + @if (config.value) { + {{ config.value }}% + } - - + @if (config.value && config.remainingTime) { + - + } - {{ config.remainingTime }} remaining... + @if (config.remainingTime) { + {{ config.remainingTime }} remaining... + }
diff --git a/src/lib/loading/progress-loading/progress-loading.component.ts b/src/lib/loading/progress-loading/progress-loading.component.ts index 66b42a0..a7a02c1 100644 --- a/src/lib/loading/progress-loading/progress-loading.component.ts +++ b/src/lib/loading/progress-loading/progress-loading.component.ts @@ -6,6 +6,7 @@ import { ILoadingConfig } from '../loading.service'; templateUrl: './progress-loading.component.html', styleUrls: ['./progress-loading.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, }) export class ProgressLoadingComponent { @Input() config!: ILoadingConfig; diff --git a/src/lib/pagination/index.ts b/src/lib/pagination/index.ts new file mode 100644 index 0000000..bb97fd9 --- /dev/null +++ b/src/lib/pagination/index.ts @@ -0,0 +1,2 @@ +export * from './pagination-settings'; +export * from './pagination.component'; diff --git a/src/lib/pagination/pagination-settings.ts b/src/lib/pagination/pagination-settings.ts new file mode 100644 index 0000000..6f8453c --- /dev/null +++ b/src/lib/pagination/pagination-settings.ts @@ -0,0 +1,4 @@ +export interface PaginationSettings { + currentPage: number; + totalPages: number; +} diff --git a/src/lib/pagination/pagination.component.html b/src/lib/pagination/pagination.component.html new file mode 100644 index 0000000..7e7d022 --- /dev/null +++ b/src/lib/pagination/pagination.component.html @@ -0,0 +1,27 @@ +
+| +@for (page of displayedPages; track page) { +
+ {{ displayValue(page) }} +
+} +| +
diff --git a/src/lib/pagination/pagination.component.scss b/src/lib/pagination/pagination.component.scss new file mode 100644 index 0000000..fae36a8 --- /dev/null +++ b/src/lib/pagination/pagination.component.scss @@ -0,0 +1,27 @@ +:host { + display: flex; + + > *:not(:last-child) { + margin-right: 12px; + } + + .disabled, + span { + opacity: 0.5; + pointer-events: none; + } + + .page { + cursor: pointer; + + &.disabled, + &.dots { + cursor: default; + } + + &.active { + color: var(--iqser-primary); + font-weight: bold; + } + } +} diff --git a/src/lib/pagination/pagination.component.ts b/src/lib/pagination/pagination.component.ts new file mode 100644 index 0000000..c07832b --- /dev/null +++ b/src/lib/pagination/pagination.component.ts @@ -0,0 +1,65 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; + +import { TranslateModule } from '@ngx-translate/core'; +import { PaginationSettings } from './pagination-settings'; + +@Component({ + selector: 'iqser-pagination', + templateUrl: './pagination.component.html', + styleUrls: ['./pagination.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TranslateModule], +}) +export class PaginationComponent { + displayedPages: (number | string)[] = []; + @Output() pageChanged = new EventEmitter(); + + private _currentPage: number = 0; + + get currentPage() { + return this._currentPage; + } + + private _totalPages: number = 0; + + get totalPages() { + return this._totalPages; + } + + @Input() + set settings(value: PaginationSettings) { + this._currentPage = value.currentPage; + this._totalPages = value.totalPages; + this._updatePagesArray(); + } + + selectPage(page: number | string) { + if (page !== '...') { + this.pageChanged.emit(page as number); + } + } + + displayValue(page: number | string) { + return page === '...' ? page : (page as number) + 1; + } + + isNumber(page: number | string) { + return Number.isInteger(page); + } + + private _updatePagesArray() { + this.displayedPages = [0]; + if (Math.max(1, this.currentPage - 1) > 1) { + this.displayedPages.push('...'); + } + for (let page = Math.max(1, this.currentPage - 1); page <= Math.min(this.currentPage + 1, this.totalPages - 1); ++page) { + this.displayedPages.push(page); + } + if (Math.min(this.currentPage + 1, this.totalPages - 1) !== this.totalPages - 1) { + if (this.currentPage + 1 < this.totalPages - 2) { + this.displayedPages.push('...'); + } + this.displayedPages.push(this.totalPages - 1); + } + } +} diff --git a/src/lib/permissions/README.md b/src/lib/permissions/README.md index 40bd849..b8527ef 100644 --- a/src/lib/permissions/README.md +++ b/src/lib/permissions/README.md @@ -1,24 +1,23 @@ ```typescript -import { Component, OnInit } from "@angular/core"; -import { HttpClient } from "@angular/common/http"; -import { IqserPermissionsService } from "./permissions.service"; -import { IqserRolesService } from "./roles.service"; +import { Component, OnInit } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { IqserPermissionsService } from './permissions.service'; +import { IqserRolesService } from './roles.service'; @Component({ - templateUrl: "./app.component.html" + templateUrl: './app.component.html', }) export class AppComponent implements OnInit { constructor( private permissionsService: IqserPermissionsService, - private rolesService: IqserRolesService - ) { - } + private rolesService: IqserRolesService, + ) {} ngOnInit(): void { - const perm = ["can-edit-articles", "can-read-articles"]; + const perm = ['can-edit-articles', 'can-read-articles']; this.permissionsService.load(perm); - const roles = ["ADMIN", "EDITOR"]; + const roles = ['ADMIN', 'EDITOR']; this.rolesService.load(roles); } } @@ -82,21 +81,21 @@ export class AppComponent implements OnInit { ``` ```typescript -import { IqserRoute } from "./models/permissions-router-data.model"; -import { IqserPermissionsGuard } from "./permissions-guard.service"; +import { IqserRoute } from './models/permissions-router-data.model'; +import { IqserPermissionsGuard } from './permissions-guard.service'; const appRoutes: IqserRoute[] = [ { - path: "home", + path: 'home', component: HomeComponent, canActivate: [IqserPermissionsGuard], data: { permissions: { - allow: ["ADMIN", "MODERATOR"], - redirectTo: "/another-route" - } - } - } + allow: ['ADMIN', 'MODERATOR'], + redirectTo: '/another-route', + }, + }, + }, ]; const appRoutes1: IqserRoute[] = [ @@ -108,73 +107,73 @@ const appRoutes1: IqserRoute[] = [ permissions: { allow: (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { if (route.params['id'] === 42) { - return ['MANAGER', "UTILS"] + return ['MANAGER', 'UTILS']; } else { - return 'ADMIN' + return 'ADMIN'; } - } - } - } - } + }, + }, + }, + }, ]; const appRoutes2: IqserRoute[] = [ { - path: "home", + path: 'home', component: HomeComponent, canActivate: [IqserPermissionsGuard], data: { permissions: { - allow: ["ADMIN", "MODERATOR"], + allow: ['ADMIN', 'MODERATOR'], redirectTo: { - navigationCommands: ["123"], + navigationCommands: ['123'], navigationExtras: { - skipLocationChange: true - } - } - } + skipLocationChange: true, + }, + }, + }, }, - } + }, ]; const appRoutes3: IqserRoute[] = [ { - path: "home", + path: 'home', component: HomeComponent, canActivate: [IqserPermissionsGuard], data: { permissions: { - allow: ["canReadAgenda", "canEditAgenda"], + allow: ['canReadAgenda', 'canEditAgenda'], redirectTo: { - canReadAgenda: "agendaList", - canEditAgenda: "dashboard", - default: "login" - } - } - } - } + canReadAgenda: 'agendaList', + canEditAgenda: 'dashboard', + default: 'login', + }, + }, + }, + }, ]; const appRoutes4: IqserRoute[] = [ { - path: "home", + path: 'home', component: HomeComponent, canActivate: [IqserPermissionsGuard], data: { permissions: { - allow: ["canEditAgenda"], + allow: ['canEditAgenda'], redirectTo: { canEditAgenda: { - navigationCommands: "dashboard", + navigationCommands: 'dashboard', navigationExtras: { - skipLocationChange: true - } + skipLocationChange: true, + }, }, - default: "login" - } - } - } - } + default: 'login', + }, + }, + }, + }, ]; const appRoutes5: IqserRoute[] = [ @@ -186,22 +185,29 @@ const appRoutes5: IqserRoute[] = [ permissions: { allow: ['canReadAgenda', 'canEditAgenda'], redirectTo: { - canReadAgenda: (rejectedPermissionName: string, activateRouteSnapshot: ActivatedRouteSnapshot, routeStateSnapshot: RouterStateSnapshot) => { + canReadAgenda: ( + rejectedPermissionName: string, + activateRouteSnapshot: ActivatedRouteSnapshot, + routeStateSnapshot: RouterStateSnapshot, + ) => { return 'dashboard'; }, - canEditAgenda: (rejectedPermissionName: string, activateRouteSnapshot: ActivatedRouteSnapshot, routeStateSnapshot: RouterStateSnapshot) => { + canEditAgenda: ( + rejectedPermissionName: string, + activateRouteSnapshot: ActivatedRouteSnapshot, + routeStateSnapshot: RouterStateSnapshot, + ) => { return { navigationCommands: ['/dashboard'], navigationExtras: { - skipLocationChange: true - } - } + skipLocationChange: true, + }, + }; }, - default: 'login' - } - } - } + default: 'login', + }, + }, + }, }, ]; - ``` diff --git a/src/lib/permissions/directives/allow.directive.ts b/src/lib/permissions/directives/allow.directive.ts index 7f423e8..f526e45 100644 --- a/src/lib/permissions/directives/allow.directive.ts +++ b/src/lib/permissions/directives/allow.directive.ts @@ -6,7 +6,6 @@ import { assertTemplate, IqserPermissionsDirective } from './permissions.directi @Directive({ selector: '[allow]', - standalone: true, }) export class IqserAllowDirective extends IqserPermissionsDirective implements OnDestroy, OnInit { /** diff --git a/src/lib/permissions/directives/deny.directive.spec.ts b/src/lib/permissions/directives/deny.directive.spec.ts index 46c3ce5..4ece03f 100644 --- a/src/lib/permissions/directives/deny.directive.spec.ts +++ b/src/lib/permissions/directives/deny.directive.spec.ts @@ -291,7 +291,7 @@ describe('Permission directive angular testing different async functions in role })); it('should hide the component when one returns falsy value', fakeAsync(() => { - let content = getFixtureContent(); + const content = getFixtureContent(); expect(content).toBeTruthy(); expect(content.innerHTML).toEqual('
123
'); diff --git a/src/lib/permissions/directives/deny.directive.ts b/src/lib/permissions/directives/deny.directive.ts index 64df945..edd40c1 100644 --- a/src/lib/permissions/directives/deny.directive.ts +++ b/src/lib/permissions/directives/deny.directive.ts @@ -6,7 +6,6 @@ import { assertTemplate, IqserPermissionsDirective } from './permissions.directi @Directive({ selector: '[deny]', - standalone: true, }) export class IqserDenyDirective extends IqserPermissionsDirective implements OnDestroy, OnInit { /** diff --git a/src/lib/permissions/services/permissions-guard.service.ts b/src/lib/permissions/services/permissions-guard.service.ts index c02444d..7051712 100644 --- a/src/lib/permissions/services/permissions-guard.service.ts +++ b/src/lib/permissions/services/permissions-guard.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, CanMatch, Route, Router, RouterStateSnapshot } from '@angular/router'; +import { NGXLogger } from 'ngx-logger'; import { firstValueFrom, from, of } from 'rxjs'; import { first, mergeMap } from 'rxjs/operators'; @@ -7,23 +8,16 @@ import { first, mergeMap } from 'rxjs/operators'; import { DEFAULT_REDIRECT_KEY, IqserActivatedRouteSnapshot, + IqserPermissionsData, IqserRoute, NavigationCommandsFn, NavigationExtrasFn, RedirectTo, RedirectToFn, } from '../types'; +import { isArray, isFunction, isRedirectWithParameters, isString, transformPermission } from '../utils'; import { IqserPermissionsService } from './permissions.service'; import { IqserRolesService } from './roles.service'; -import { isArray, isFunction, isRedirectWithParameters, isString, transformPermission } from '../utils'; -import { List } from '../../utils'; -import { TenantsService } from '../../tenants'; -import { NGXLogger } from 'ngx-logger'; - -export interface IqserPermissionsData { - readonly allow: string | List; - readonly redirectTo?: RedirectTo | RedirectToFn; -} @Injectable({ providedIn: 'root', @@ -31,7 +25,6 @@ export interface IqserPermissionsData { export class IqserPermissionsGuard implements CanActivate, CanMatch, CanActivateChild { constructor( private readonly _permissionsService: IqserPermissionsService, - private readonly _tenantsService: TenantsService, private readonly _rolesService: IqserRolesService, private readonly _router: Router, private readonly _logger: NGXLogger, @@ -93,7 +86,7 @@ export class IqserPermissionsGuard implements CanActivate, CanMatch, CanActivate ' with extras ', navigationExtras, ); - return this._router.navigate([this._tenantsService.activeTenantId, ...navigationCommands], navigationExtras); + return this._router.navigate([...navigationCommands], navigationExtras); } if (Array.isArray(_redirectTo)) { @@ -105,7 +98,7 @@ export class IqserPermissionsGuard implements CanActivate, CanMatch, CanActivate '. Redirecting to ', _redirectTo, ); - return this._router.navigate([this._tenantsService.activeTenantId, ..._redirectTo]); + return this._router.navigate([..._redirectTo]); } this._logger.warn( @@ -116,7 +109,7 @@ export class IqserPermissionsGuard implements CanActivate, CanMatch, CanActivate '. Redirecting to ', _redirectTo, ); - return this._router.navigate([`${this._tenantsService.activeTenantId}${_redirectTo}`]); + return this._router.navigate([_redirectTo]); } #checkRedirect(permissions: IqserPermissionsData, route: ActivatedRouteSnapshot | Route, state?: RouterStateSnapshot) { diff --git a/src/lib/permissions/services/permissions.service.ts b/src/lib/permissions/services/permissions.service.ts index 948e2aa..41a4236 100644 --- a/src/lib/permissions/services/permissions.service.ts +++ b/src/lib/permissions/services/permissions.service.ts @@ -1,18 +1,18 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { List } from '../../utils/types/iqser-types'; +import { IqserPermissions, PermissionValidationFn } from '../types'; import { isArray, isString, toArray } from '../utils'; -import { IqserPermissions, PermissionValidationFn } from '../types'; -import { List } from '../../utils'; -import { map } from 'rxjs/operators'; @Injectable({ providedIn: 'root', }) export class IqserPermissionsService { - readonly permissions$: Observable; readonly #permissions$ = new BehaviorSubject({}); + readonly permissions$: Observable; constructor() { this.permissions$ = this.#permissions$.asObservable(); diff --git a/src/lib/permissions/services/roles.service.ts b/src/lib/permissions/services/roles.service.ts index 82bb1c6..d877c36 100644 --- a/src/lib/permissions/services/roles.service.ts +++ b/src/lib/permissions/services/roles.service.ts @@ -1,17 +1,17 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; +import { List } from '../../utils/types/iqser-types'; import { IqserRoles, RoleValidationFn } from '../types'; import { isArray, isBoolean, isString, toArray } from '../utils'; -import { List } from '../../utils'; import { IqserPermissionsService } from './permissions.service'; @Injectable({ providedIn: 'root', }) export class IqserRolesService { - readonly roles$: Observable; readonly #roles$ = new BehaviorSubject({}); + readonly roles$: Observable; constructor(private readonly _permissionsService: IqserPermissionsService) { this.roles$ = this.#roles$.asObservable(); diff --git a/src/lib/permissions/types.ts b/src/lib/permissions/types.ts index c2ba713..365b06c 100644 --- a/src/lib/permissions/types.ts +++ b/src/lib/permissions/types.ts @@ -1,5 +1,5 @@ import { ActivatedRouteSnapshot, NavigationExtras, Route, RouterStateSnapshot } from '@angular/router'; -import { List } from '../utils'; +import { List } from '../utils/types/iqser-types'; export type IqserPermissions = Record; export type IqserRoles = Record; @@ -9,6 +9,11 @@ export interface IqserPermissionsRouterData { redirectTo?: RedirectTo | RedirectToFn; } +export interface IqserPermissionsData { + readonly allow: string | List; + readonly redirectTo?: RedirectTo | RedirectToFn; +} + export interface IqserRedirectToNavigationParameters { navigationCommands: any[] | NavigationCommandsFn; navigationExtras?: NavigationExtras | NavigationExtrasFn; diff --git a/src/lib/permissions/utils.ts b/src/lib/permissions/utils.ts index da56331..1a1785a 100644 --- a/src/lib/permissions/utils.ts +++ b/src/lib/permissions/utils.ts @@ -1,7 +1,6 @@ -import { AllowFn, IqserPermissionsRouterData, IqserRedirectToNavigationParameters } from './types'; import { ActivatedRouteSnapshot, Route, RouterStateSnapshot } from '@angular/router'; -import { IqserPermissionsData } from './services/permissions-guard.service'; import { List } from '../utils'; +import { AllowFn, IqserPermissionsData, IqserPermissionsRouterData, IqserRedirectToNavigationParameters } from './types'; export function isFunction(value: unknown): value is T { return typeof value === 'function'; @@ -30,7 +29,7 @@ export function isArray(value: unknown): value is T[] { } export function toArray(value?: string | List): List { - return isString(value) ? [value] : value ?? []; + return isString(value) ? [value] : (value ?? []); } export function isRedirectWithParameters(object: any | IqserRedirectToNavigationParameters): object is IqserRedirectToNavigationParameters { diff --git a/src/lib/pipes/capitalize.pipe.ts b/src/lib/pipes/capitalize.pipe.ts index 6ea45d7..3055a6d 100644 --- a/src/lib/pipes/capitalize.pipe.ts +++ b/src/lib/pipes/capitalize.pipe.ts @@ -3,7 +3,6 @@ import { capitalize } from '../utils'; @Pipe({ name: 'capitalize', - standalone: true, }) export class CapitalizePipe implements PipeTransform { transform(value: string): string { diff --git a/src/lib/pipes/humanize-camel-case.pipe.ts b/src/lib/pipes/humanize-camel-case.pipe.ts index 11136f8..fe2fd75 100644 --- a/src/lib/pipes/humanize-camel-case.pipe.ts +++ b/src/lib/pipes/humanize-camel-case.pipe.ts @@ -3,7 +3,6 @@ import { humanizeCamelCase } from '../utils'; @Pipe({ name: 'humanizeCamelCase', - standalone: true, }) export class HumanizeCamelCasePipe implements PipeTransform { transform(item: string): string { diff --git a/src/lib/pipes/humanize.pipe.ts b/src/lib/pipes/humanize.pipe.ts index 2535904..18a04fb 100644 --- a/src/lib/pipes/humanize.pipe.ts +++ b/src/lib/pipes/humanize.pipe.ts @@ -3,7 +3,6 @@ import { humanize } from '../utils'; @Pipe({ name: 'humanize', - standalone: true, }) export class HumanizePipe implements PipeTransform { transform(item: string, lowercase = false): string { diff --git a/src/lib/pipes/log.pipe.ts b/src/lib/pipes/log.pipe.ts index 8a85372..9231cb3 100644 --- a/src/lib/pipes/log.pipe.ts +++ b/src/lib/pipes/log.pipe.ts @@ -2,7 +2,6 @@ import { Pipe, PipeTransform } from '@angular/core'; @Pipe({ name: 'log', - standalone: true, }) export class LogPipe implements PipeTransform { transform(value: T, message = ''): T { diff --git a/src/lib/pipes/replace-nbsp.pipe.ts b/src/lib/pipes/replace-nbsp.pipe.ts index a930af9..7acb171 100644 --- a/src/lib/pipes/replace-nbsp.pipe.ts +++ b/src/lib/pipes/replace-nbsp.pipe.ts @@ -2,7 +2,6 @@ import { Pipe, PipeTransform } from '@angular/core'; @Pipe({ name: 'replaceNbsp', - standalone: true, }) export class ReplaceNbspPipe implements PipeTransform { transform(value: string): string { diff --git a/src/lib/pipes/size.pipe.ts b/src/lib/pipes/size.pipe.ts index 2bece27..b8b3da4 100644 --- a/src/lib/pipes/size.pipe.ts +++ b/src/lib/pipes/size.pipe.ts @@ -3,7 +3,6 @@ import { size } from '../utils'; @Pipe({ name: 'size', - standalone: true, }) export class SizePipe implements PipeTransform { transform(value: number): string { diff --git a/src/lib/pipes/snake-case.pipe.ts b/src/lib/pipes/snake-case.pipe.ts new file mode 100644 index 0000000..bbc9ee5 --- /dev/null +++ b/src/lib/pipes/snake-case.pipe.ts @@ -0,0 +1,13 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'snakeCase', +}) +export class SnakeCasePipe implements PipeTransform { + transform(value: string): string | undefined { + if (!value) { + return undefined; + } + return value.toLowerCase().replaceAll(' ', '_'); + } +} diff --git a/src/lib/search/search.service.ts b/src/lib/search/search.service.ts index 125b289..c6d0235 100644 --- a/src/lib/search/search.service.ts +++ b/src/lib/search/search.service.ts @@ -1,12 +1,13 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; -import { Id, IListable } from '../listing'; -import { shareDistinctLast } from '../utils'; +import { IListable } from '../listing/models/listable'; +import { Id } from '../listing/models/trackable'; +import { shareDistinctLast } from '../utils/operators'; @Injectable() export class SearchService, PrimaryKey extends Id = T['id']> { - skip = false; private readonly _query$ = new BehaviorSubject(''); + skip = false; readonly valueChanges$ = this._query$.asObservable().pipe(shareDistinctLast()); get searchValue(): string { diff --git a/src/lib/services/api-path.interceptor.ts b/src/lib/services/api-path.interceptor.ts index 9d97ae1..0eb3852 100644 --- a/src/lib/services/api-path.interceptor.ts +++ b/src/lib/services/api-path.interceptor.ts @@ -1,13 +1,13 @@ import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { Observable } from 'rxjs'; +import { UI_ROOT_PATH_FN } from '../utils/tokens'; import { getConfig } from './iqser-config.service'; -import { BASE_HREF } from '../utils'; @Injectable() export class ApiPathInterceptor implements HttpInterceptor { readonly #config = getConfig(); - readonly #baseHref = inject(BASE_HREF); + readonly #convertPath = inject(UI_ROOT_PATH_FN); intercept(req: HttpRequest, next: HttpHandler): Observable> { if (!req.url.startsWith('/assets')) { @@ -15,7 +15,7 @@ export class ApiPathInterceptor implements HttpInterceptor { return next.handle(req.clone({ url: apiUrl })); } - const url = this.#baseHref + req.url; + const url = this.#convertPath(req.url); return next.handle(req.clone({ url })); } } diff --git a/src/lib/services/composite-route.guard.ts b/src/lib/services/composite-route.guard.ts index 99f58ec..510e99f 100644 --- a/src/lib/services/composite-route.guard.ts +++ b/src/lib/services/composite-route.guard.ts @@ -1,7 +1,8 @@ -import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot, UrlTree } from '@angular/router'; -import { Injectable, InjectionToken, Injector } from '@angular/core'; -import { firstValueFrom, from, of } from 'rxjs'; -import { LoadingService } from '../loading'; +import { inject, Injectable, InjectionToken, Injector, runInInjectionContext } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, CanActivateFn, GuardResult, RouterStateSnapshot, UrlTree } from '@angular/router'; +import { concatMap, firstValueFrom, from, last, Observable, of, takeWhile } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { LoadingService } from '../loading/loading.service'; import { SkeletonService } from './skeleton.service'; @Injectable({ @@ -22,12 +23,13 @@ export class CompositeRouteGuard implements CanActivate { if (routeGuards) { for (let i = 0; i < routeGuards.length; i++) { const routeGuard = this._injector.get(routeGuards[i]); - let canActivateResult = routeGuard.canActivate(route, state); - if (canActivateResult instanceof Promise) { - canActivateResult = from(canActivateResult); + const canActivate = routeGuard.canActivate(route, state); + let canActivateResult: Observable = of(true); + if (canActivate instanceof Promise) { + canActivateResult = from(canActivate); } - if (typeof canActivateResult === 'boolean' || canActivateResult instanceof UrlTree) { - canActivateResult = of(canActivateResult); + if (typeof canActivate === 'boolean' || canActivate instanceof UrlTree) { + canActivateResult = of(canActivate); } const result = await firstValueFrom(canActivateResult); @@ -62,3 +64,45 @@ export class CompositeRouteGuard implements CanActivate { } } } + +export type AsyncGuard = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => Promise; + +export function orderedAsyncGuards(guards: Array): CanActivateFn { + return (route, state) => { + const injector = inject(Injector); + const loadingService = inject(LoadingService); + + return from(guards).pipe( + tap(() => loadingService.start()), + // For each guard, fire canActivate and wait for it + // to complete. + concatMap(guard => runInInjectionContext(injector, () => guard(route, state))), + // Don't execute the next guard if the current guard's + // result is not true. + takeWhile(value => value === true, /* inclusive */ true), + // Return the last guard's result. + last(), + tap(() => loadingService.stop()), + ); + }; +} + +export function unorderedAsyncGuards(guards: Array): CanActivateFn { + return async (route, state) => { + const injector = inject(Injector); + const loadingService = inject(LoadingService); + + loadingService.start(); + + try { + const result = await Promise.all(guards.map(guard => runInInjectionContext(injector, () => guard(route, state)))); + loadingService.stop(); + return result.every(Boolean); + } catch (error) { + console.error(error); + loadingService.stop(); + } + + return false; + }; +} diff --git a/src/lib/services/entities-map.service.ts b/src/lib/services/entities-map.service.ts index 5f2ea90..2e0565c 100644 --- a/src/lib/services/entities-map.service.ts +++ b/src/lib/services/entities-map.service.ts @@ -1,16 +1,18 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable, Subject } from 'rxjs'; import { filter, map, startWith } from 'rxjs/operators'; -import { Entity, Id } from '../listing'; -import { List, shareLast } from '../utils'; -import { isArray } from '../permissions'; +import { Entity } from '../listing/models/entity.model'; +import { Id } from '../listing/models/trackable'; +import { isArray } from '../permissions/utils'; +import { shareLast } from '../utils/operators'; +import { List } from '../utils/types/iqser-types'; @Injectable() export abstract class EntitiesMapService, PrimaryKey extends Id = Class['id']> { - protected readonly _map = new Map>(); readonly #entityChanged$ = new Subject(); readonly #entitiesChanged$ = new BehaviorSubject(false); readonly #entityDeleted$ = new Subject(); + protected readonly _map = new Map>(); get empty(): boolean { return this._map.size === 0; diff --git a/src/lib/services/generic.service.ts b/src/lib/services/generic.service.ts index 5e1c872..62f307e 100644 --- a/src/lib/services/generic.service.ts +++ b/src/lib/services/generic.service.ts @@ -1,17 +1,11 @@ import { HttpClient, HttpEvent, HttpParams } from '@angular/common/http'; import { inject } from '@angular/core'; import { Observable } from 'rxjs'; -import { HeadersConfiguration, List } from '../utils'; import { map } from 'rxjs/operators'; +import { HeadersConfiguration } from '../utils/headers-configuration'; +import { List } from '../utils/types/iqser-types'; export const ROOT_CHANGES_KEY = 'root'; -export const LAST_CHECKED_OFFSET = 30000; - -export interface HeaderOptions { - readonly authorization?: boolean; - readonly accept?: boolean; - readonly contentType?: boolean; -} export interface QueryParam { readonly key: string; @@ -24,9 +18,7 @@ export interface QueryParam { */ export abstract class GenericService { protected readonly _http = inject(HttpClient); - protected readonly _lastCheckedForChanges = new Map([ - [ROOT_CHANGES_KEY, new Date(Date.now() - LAST_CHECKED_OFFSET).toISOString()], - ]); + protected readonly _lastCheckedForChanges = new Map([[ROOT_CHANGES_KEY, new Date(Date.now()).toISOString()]]); protected abstract readonly _defaultModelPath: string; protected readonly _serviceName: string = 'redaction-gateway-v1'; @@ -112,7 +104,7 @@ export abstract class GenericService { protected _getOne(path: List, modelPath = this._defaultModelPath, queryParams?: List): Observable { const entityPath = path.map(item => encodeURIComponent(item)).join('/'); - return this._http.get(`/${this._serviceName}/${encodeURI(modelPath)}/${entityPath}`, { + return this._http.get(`/${this._serviceName}/${encodeURI(modelPath)}/${entityPath}`.replace(/\/+$/, ''), { headers: HeadersConfiguration.getHeaders({ contentType: false }), params: this._queryParams(queryParams), observe: 'body', @@ -131,6 +123,6 @@ export abstract class GenericService { } protected _updateLastChanged(key = ROOT_CHANGES_KEY): void { - this._lastCheckedForChanges.set(key, new Date(Date.now() - LAST_CHECKED_OFFSET).toISOString()); + this._lastCheckedForChanges.set(key, new Date().toISOString()); } } diff --git a/src/lib/services/iqser-config.service.ts b/src/lib/services/iqser-config.service.ts index b0189b8..a4f2725 100644 --- a/src/lib/services/iqser-config.service.ts +++ b/src/lib/services/iqser-config.service.ts @@ -1,15 +1,20 @@ import { Inject, inject, Injectable } from '@angular/core'; import { Title } from '@angular/platform-browser'; -import { CacheApiService, wipeAllCaches } from '../caching'; -import { IqserAppConfig } from '../utils'; +import { CacheApiService } from '../caching/cache-api.service'; +import { wipeAllCaches } from '../caching/cache-utils'; +import { IqserAppConfig } from '../utils/iqser-app-config'; +import { LANDING_PAGE_THEMES, MANUAL_BASE_URL, THEME_DIRECTORIES } from '../utils/constants'; +import { IStoredTenantId, TenantsService } from '../tenants'; @Injectable() export class IqserConfigService { protected readonly _cacheApiService = inject(CacheApiService); protected readonly _titleService = inject(Title); + protected readonly _tenantsService = inject(TenantsService); constructor(@Inject('Doesnt matter') protected _values: T) { this._checkFrontendVersion(); + this.#updateAppType(); this._titleService.setTitle(this._values.APP_NAME); } @@ -22,6 +27,16 @@ export class IqserConfigService { this._titleService.setTitle(this._values.APP_NAME); } + updateIsDocumine(tenant: IStoredTenantId): void { + const isDocumine = tenant.documine; + this._values = { + ...this._values, + IS_DOCUMINE: isDocumine, + THEME: !isDocumine ? THEME_DIRECTORIES.REDACT : THEME_DIRECTORIES.SCM, + MANUAL_BASE_URL: !isDocumine ? MANUAL_BASE_URL.REDACT_MANAGER : MANUAL_BASE_URL.DOCUMINE, + }; + } + protected _checkFrontendVersion(): void { this._cacheApiService.getCachedValue('FRONTEND_APP_VERSION').then(async lastVersion => { const version = this._values.FRONTEND_APP_VERSION; @@ -33,6 +48,28 @@ export class IqserConfigService { await this._cacheApiService.cacheValue('FRONTEND_APP_VERSION', version); }); } + + #updateAppType() { + const storedTenants = this._tenantsService.getStoredTenants(); + if (storedTenants.length) { + const isRedaction = !!storedTenants.find(t => !t.documine); + const isDocumine = !!storedTenants.find(t => t.documine); + const landingPageTheme = + isRedaction && isDocumine + ? LANDING_PAGE_THEMES.MIXED + : isDocumine + ? LANDING_PAGE_THEMES.DOCUMINE + : LANDING_PAGE_THEMES.REDACT_MANAGER; + + this._values = { + ...this._values, + LANDING_PAGE_THEME: landingPageTheme, + APP_NAME: landingPageTheme === LANDING_PAGE_THEMES.MIXED ? 'Knecon Cloud' : isDocumine ? 'DocuMine' : 'RedactManager', + IS_DOCUMINE: isDocumine, + THEME: !isDocumine ? THEME_DIRECTORIES.REDACT : THEME_DIRECTORIES.SCM, + }; + } + } } export function getConfig() { diff --git a/src/lib/services/iqser-user-preference.service.ts b/src/lib/services/iqser-user-preference.service.ts index 104b4cf..961c25c 100644 --- a/src/lib/services/iqser-user-preference.service.ts +++ b/src/lib/services/iqser-user-preference.service.ts @@ -1,20 +1,22 @@ +import { APP_BASE_HREF } from '@angular/common'; import { inject, Injectable } from '@angular/core'; import { firstValueFrom } from 'rxjs'; -import { BASE_HREF, List } from '../utils'; +import { List } from '../utils'; import { GenericService } from './generic.service'; export type UserAttributes = Record; -const KEYS = { +export const KEYS = { language: 'Language', theme: 'Theme', + helpModeDialog: 'HelpModeDialog', } as const; @Injectable() export abstract class IqserUserPreferenceService extends GenericService { #userAttributes: UserAttributes = {}; protected abstract readonly _devFeaturesEnabledKey: string; - protected readonly _serviceName: string = 'tenant-user-management'; + protected override readonly _serviceName: string = 'tenant-user-management'; get userAttributes(): UserAttributes { return this.#userAttributes; @@ -22,7 +24,7 @@ export abstract class IqserUserPreferenceService extends GenericService { await this.save(KEYS.language, language); } + getHelpModeDialog(): boolean { + return this._getAttribute(KEYS.helpModeDialog, 'false') === 'true'; + } + + async toggleHelpModeDialog(): Promise { + const nextValue = (!this.getHelpModeDialog()).toString(); + await this.save(KEYS.helpModeDialog, nextValue); + } + toggleDevFeatures(): void { sessionStorage.setItem(this._devFeaturesEnabledKey, String(!this.isIqserDevMode)); window.location.reload(); @@ -72,7 +83,7 @@ export abstract class IqserUserPreferenceService extends GenericService { + readonly #http = inject(HttpClient); + readonly #map = new Map>(); protected abstract readonly _primaryKey: string; protected abstract readonly _entityClass: new (entityInterface: I, ...args: unknown[]) => E; protected abstract readonly _defaultModelPath: string; protected readonly _serviceName: string = 'redaction-gateway-v1'; - readonly #http = inject(HttpClient); - readonly #map = new Map>(); - getFor(ids: string[]): Observable { const request = this.#http.post(`/${this._serviceName}/${encodeURI(this._defaultModelPath)}`, ids, { headers: HeadersConfiguration.getHeaders(), @@ -26,6 +26,14 @@ export abstract class StatsService { ); } + getOne(id: string): Observable { + const request = this.#http.get(`/${this._serviceName}/${encodeURI(this._defaultModelPath)}/${id}`, { + headers: HeadersConfiguration.getHeaders(), + }); + + return request.pipe(map(entity => new this._entityClass(entity))); + } + get(key: string): E { return this._getBehaviourSubject(key).value; } diff --git a/src/lib/services/toaster.service.ts b/src/lib/services/toaster.service.ts index 726cc7d..55b2bc5 100644 --- a/src/lib/services/toaster.service.ts +++ b/src/lib/services/toaster.service.ts @@ -1,12 +1,13 @@ +import { HttpErrorResponse, HttpStatusCode } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { ActiveToast, ToastrService } from 'ngx-toastr'; -import { IndividualConfig } from 'ngx-toastr/toastr/toastr-config'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NavigationStart, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { HttpErrorResponse, HttpStatusCode } from '@angular/common/http'; +import { ActiveToast, ToastrService } from 'ngx-toastr'; +import { IndividualConfig } from 'ngx-toastr/toastr/toastr-config'; import { filter, tap } from 'rxjs/operators'; import { ErrorMessageService } from './error-message.service'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { isIqserDevMode } from './iqser-user-preference.service'; const enum NotificationType { SUCCESS = 'SUCCESS', @@ -26,6 +27,7 @@ export interface ToasterOptions extends IndividualConfig { */ readonly params?: Record; readonly actions?: ToasterActions[]; + readonly useRaw?: boolean; } export interface ErrorToasterOptions extends ToasterOptions { @@ -35,10 +37,19 @@ export interface ErrorToasterOptions extends ToasterOptions { readonly error?: HttpErrorResponse; } +const defaultDevToastOptions: Partial = { + timeOut: 10000, + easing: 'ease-in-out', + easeTime: 500, + useRaw: true, +}; + @Injectable({ providedIn: 'root', }) export class Toaster { + readonly #isIqserDevMode = isIqserDevMode(); + constructor( router: Router, private readonly _toastr: ToastrService, @@ -65,10 +76,22 @@ export class Toaster { return this._toastr.error(resultedMsg, options?.title, options); } + rawError(message: string, config?: Partial>) { + return this._toastr.error(message, undefined, config); + } + info(message: string, options?: Partial): ActiveToast { return this.#showToastNotification(message, NotificationType.INFO, options); } + devInfo(message: string): ActiveToast | undefined { + if (!this.#isIqserDevMode) { + return; + } + + return this.info(message, defaultDevToastOptions); + } + success(message: string, options?: Partial): ActiveToast { return this.#showToastNotification(message, NotificationType.SUCCESS, options); } @@ -82,16 +105,16 @@ export class Toaster { notificationType = NotificationType.INFO, options?: Partial, ): ActiveToast { - const translatedMsg = this._translateService.instant(message, options?.params) as string; + const msg = options?.useRaw ? message : (this._translateService.instant(message, options?.params) as string); switch (notificationType) { case NotificationType.SUCCESS: - return this._toastr.success(translatedMsg, options?.title, options); + return this._toastr.success(msg, options?.title, options); case NotificationType.WARNING: - return this._toastr.warning(translatedMsg, options?.title, options); + return this._toastr.warning(msg, options?.title, options); case NotificationType.INFO: default: - return this._toastr.info(translatedMsg, options?.title, options); + return this._toastr.info(msg, options?.title, options); } } } diff --git a/src/lib/shared/logo/logo.component.ts b/src/lib/shared/logo/logo.component.ts index adccb87..ba32c5e 100644 --- a/src/lib/shared/logo/logo.component.ts +++ b/src/lib/shared/logo/logo.component.ts @@ -1,9 +1,9 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; @Component({ selector: 'iqser-logo', - template: ` `, + template: ``, styles: [ ` :host { @@ -17,9 +17,8 @@ import { MatIconModule } from '@angular/material/icon'; `, ], changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, imports: [MatIconModule], }) export class LogoComponent { - @Input({ required: true }) icon!: string; + readonly icon = input.required(); } diff --git a/src/lib/shared/side-nav/side-nav.component.ts b/src/lib/shared/side-nav/side-nav.component.ts index 6f90f11..ff12de5 100644 --- a/src/lib/shared/side-nav/side-nav.component.ts +++ b/src/lib/shared/side-nav/side-nav.component.ts @@ -4,7 +4,6 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; selector: 'iqser-side-nav [title]', templateUrl: './side-nav.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, }) export class SideNavComponent { @Input() title!: string; diff --git a/src/lib/shared/skeleton/skeleton.component.html b/src/lib/shared/skeleton/skeleton.component.html index a07bbfc..e927ba3 100644 --- a/src/lib/shared/skeleton/skeleton.component.html +++ b/src/lib/shared/skeleton/skeleton.component.html @@ -1,3 +1,3 @@ - +@if (type$ | async; as type) { - +} diff --git a/src/lib/shared/skeleton/skeleton.component.ts b/src/lib/shared/skeleton/skeleton.component.ts index 3f9a950..da59feb 100644 --- a/src/lib/shared/skeleton/skeleton.component.ts +++ b/src/lib/shared/skeleton/skeleton.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, HostBinding, inject, Input, TemplateRef } from '@angular/core'; import { SkeletonService } from '../../services'; -import { getCurrentUser } from '../../users'; -import { AsyncPipe, NgForOf, NgIf, NgTemplateOutlet } from '@angular/common'; +import { IqserUserService } from '../../users'; +import { AsyncPipe, NgTemplateOutlet } from '@angular/common'; import { tap } from 'rxjs/operators'; @Component({ @@ -9,17 +9,16 @@ import { tap } from 'rxjs/operators'; templateUrl: './skeleton.component.html', styleUrls: ['./skeleton.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [NgTemplateOutlet, NgIf, AsyncPipe, NgForOf], + imports: [NgTemplateOutlet, AsyncPipe], }) export class SkeletonComponent { @Input() templates!: Record>; @HostBinding('style.display') display = 'none'; - readonly #currentUser = getCurrentUser(); + readonly iqserUserService = inject(IqserUserService); readonly type$ = inject(SkeletonService).type$.pipe( tap(type => { - this.display = type && this.#currentUser ? 'block' : 'none'; + this.display = type && this.iqserUserService.currentUser ? 'block' : 'none'; }), ); } diff --git a/src/lib/shared/small-chip/small-chip.component.ts b/src/lib/shared/small-chip/small-chip.component.ts index afc8bbe..fafa19b 100644 --- a/src/lib/shared/small-chip/small-chip.component.ts +++ b/src/lib/shared/small-chip/small-chip.component.ts @@ -13,7 +13,6 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; `, ], changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, }) export class SmallChipComponent { @Input() color!: string; diff --git a/src/lib/shared/spacer/spacer.component.ts b/src/lib/shared/spacer/spacer.component.ts index becc99a..44659b9 100644 --- a/src/lib/shared/spacer/spacer.component.ts +++ b/src/lib/shared/spacer/spacer.component.ts @@ -4,7 +4,6 @@ import { Component, HostBinding, Input } from '@angular/core'; selector: 'iqser-spacer [height]', template: '
', styleUrls: ['./spacer.component.scss'], - standalone: true, }) export class SpacerComponent { @Input({ required: true }) height!: number; diff --git a/src/lib/shared/status-bar/status-bar.component.html b/src/lib/shared/status-bar/status-bar.component.html index 804d6d8..5132689 100644 --- a/src/lib/shared/status-bar/status-bar.component.html +++ b/src/lib/shared/status-bar/status-bar.component.html @@ -1,14 +1,17 @@
-
-
- -
- {{ config.label }} + @for (config of configs; track config) { +
+
+ @if (config.label) { +
+ {{ config.label }} +
+ }
-
+ }
diff --git a/src/lib/shared/status-bar/status-bar.component.ts b/src/lib/shared/status-bar/status-bar.component.ts index 00885a7..ab9560c 100644 --- a/src/lib/shared/status-bar/status-bar.component.ts +++ b/src/lib/shared/status-bar/status-bar.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, Input, ViewEncapsulation } from '@angular/core'; import { StatusBarConfig } from './status-bar-config.model'; -import { NgClass, NgForOf, NgIf, NgStyle } from '@angular/common'; +import { NgClass, NgStyle } from '@angular/common'; import { MatTooltipModule } from '@angular/material/tooltip'; @Component({ @@ -9,8 +9,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; styleUrls: ['./status-bar.component.scss'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [NgClass, NgStyle, NgForOf, MatTooltipModule, NgIf], + imports: [NgClass, NgStyle, MatTooltipModule], }) export class StatusBarComponent { @Input() configs: readonly StatusBarConfig[] = []; diff --git a/src/lib/shared/toast/toast.component.html b/src/lib/shared/toast/toast.component.html index 5da85b2..419f55f 100644 --- a/src/lib/shared/toast/toast.component.html +++ b/src/lib/shared/toast/toast.component.html @@ -1,23 +1,35 @@
-
- {{ title }} -
+ @if (title) { +
+ {{ title }} +
+ } -
+ @if (message && options.enableHtml) { +
+ } -
- {{ message }} -
+ @if (message && !options.enableHtml) { +
+ {{ message }} +
+ } - + @if (actions?.length) { +
+ @for (action of actions; track action) { + + {{ action.title }} + + } +
+ }
- - - + @if (options.closeButton) { + + + + }
diff --git a/src/lib/shared/toast/toast.component.ts b/src/lib/shared/toast/toast.component.ts index 3b2f6a7..45e8d18 100644 --- a/src/lib/shared/toast/toast.component.ts +++ b/src/lib/shared/toast/toast.component.ts @@ -2,14 +2,13 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { Toast } from 'ngx-toastr'; import { ToasterActions, ToasterOptions } from '../../services'; import { MatIconModule } from '@angular/material/icon'; -import { NgForOf, NgIf } from '@angular/common'; + import { StopPropagationDirective } from '../../directives'; @Component({ templateUrl: './toast.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [MatIconModule, NgIf, StopPropagationDirective, NgForOf], + imports: [MatIconModule, StopPropagationDirective], }) export class ToastComponent extends Toast { get actions(): ToasterActions[] { diff --git a/src/lib/sorting/sort-by.pipe.ts b/src/lib/sorting/sort-by.pipe.ts index ff4eb03..1dbad80 100644 --- a/src/lib/sorting/sort-by.pipe.ts +++ b/src/lib/sorting/sort-by.pipe.ts @@ -3,7 +3,7 @@ import { SortingOrder } from './models/sorting-order.type'; import { KeysOf } from '../utils'; import { sort } from './functions'; -@Pipe({ name: 'sortBy', standalone: true }) +@Pipe({ name: 'sortBy' }) export class SortByPipe implements PipeTransform { transform(values: T[], order: SortingOrder, column: KeysOf): T[] { return sort(values, order, column); diff --git a/src/lib/sorting/sorting.service.ts b/src/lib/sorting/sorting.service.ts index 0970316..38d52b1 100644 --- a/src/lib/sorting/sorting.service.ts +++ b/src/lib/sorting/sorting.service.ts @@ -1,10 +1,12 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; +import { IListable } from '../listing/models/listable'; +import { Id } from '../listing/models/trackable'; +import { shareDistinctLast } from '../utils/operators'; +import { KeysOf } from '../utils/types/utility-types'; +import { sort } from './functions'; import { SortingOption } from './models/sorting-option.model'; import { SortingOrders } from './models/sorting-order.type'; -import { KeysOf, shareDistinctLast } from '../utils'; -import { Id, IListable } from '../listing'; -import { sort } from './functions'; @Injectable() export class SortingService, PrimaryKey extends Id = T['id']> { diff --git a/src/lib/tenants/guards/guards-utils.ts b/src/lib/tenants/guards/guards-utils.ts new file mode 100644 index 0000000..250ecba --- /dev/null +++ b/src/lib/tenants/guards/guards-utils.ts @@ -0,0 +1,5 @@ +export function getRouteTenant() { + const pathParams = location.pathname.split('/').filter(Boolean); + const uiPathIndex = pathParams.indexOf('ui'); + return pathParams[uiPathIndex + 1]; +} diff --git a/src/lib/tenants/guards/if-not-logged-in.guard.ts b/src/lib/tenants/guards/if-not-logged-in.guard.ts new file mode 100644 index 0000000..280daa0 --- /dev/null +++ b/src/lib/tenants/guards/if-not-logged-in.guard.ts @@ -0,0 +1,36 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { KeycloakService } from 'keycloak-angular'; +import { NGXLogger } from 'ngx-logger'; +import { getRouteTenant } from './guards-utils'; + +export function ifNotLoggedIn(): CanActivateFn { + return async () => { + const logger = inject(NGXLogger); + const router = inject(Router); + const keycloakService = inject(KeycloakService); + if (!keycloakService.getKeycloakInstance()) { + const tenant = getRouteTenant(); + if (tenant) { + logger.warn('[ROUTES] Tenant ' + tenant + ' found in route, redirecting to /main'); + await router.navigate(['main']); + return false; + } + } + + if (!keycloakService.isLoggedIn()) { + logger.info('[ROUTES] Not logged in, continuing to selected route'); + return true; + } + + const tenant = keycloakService.getKeycloakInstance().realm; + if (!tenant) { + logger.error('[ROUTES] Tenant not found in route or keycloak realm'); + return false; + } + + logger.warn('[ROUTES] Is logged in for ' + tenant + ', redirecting to /' + tenant); + await router.navigate(['/main']); + return false; + }; +} diff --git a/src/lib/tenants/index.ts b/src/lib/tenants/index.ts index 592eca8..bbd490a 100644 --- a/src/lib/tenants/index.ts +++ b/src/lib/tenants/index.ts @@ -1,6 +1,6 @@ -export * from './keycloak-initializer'; +export * from './services/keycloak-status.service'; export * from './services'; -export * from './tenant.pipe'; +export * from './keycloak-initializer'; export * from './tenants.module'; export * from './tenant-select/tenant-select.component'; -export * from './services/keycloak-status.service'; +export * from './types'; diff --git a/src/lib/tenants/keycloak-initializer.ts b/src/lib/tenants/keycloak-initializer.ts index 8ccb811..0e69652 100644 --- a/src/lib/tenants/keycloak-initializer.ts +++ b/src/lib/tenants/keycloak-initializer.ts @@ -1,35 +1,11 @@ -import { BASE_HREF, IqserAppConfig } from '../utils'; -import { KeycloakOptions, KeycloakService } from 'keycloak-angular'; -import { KeycloakStatusService } from './services/keycloak-status.service'; import { inject } from '@angular/core'; -import { getConfig } from '../services'; -import { NGXLogger } from 'ngx-logger'; import { Router } from '@angular/router'; - -export function getKeycloakOptions(baseUrl: string, config: IqserAppConfig, tenant: string): KeycloakOptions { - let oauthUrl = config.OAUTH_URL; - if (!oauthUrl.startsWith('http')) { - oauthUrl = oauthUrl.startsWith('/') ? oauthUrl : '/' + oauthUrl; - oauthUrl = window.location.origin + oauthUrl; - } - - return { - config: { - url: oauthUrl, - realm: tenant, - clientId: config.OAUTH_CLIENT_ID, - }, - initOptions: { - checkLoginIframe: false, - onLoad: 'check-sso', - silentCheckSsoRedirectUri: window.location.origin + baseUrl + '/assets/oauth/silent-refresh.html', - flow: 'standard', - enableLogging: true, - }, - enableBearerInterceptor: true, - loadUserProfileAtStartUp: true, - }; -} +import { KeycloakService } from 'keycloak-angular'; +import { NGXLogger } from 'ngx-logger'; +import { getConfig } from '../services/iqser-config.service'; +import { UI_ROOT } from '../utils/tokens'; +import { getKeycloakOptions } from './keycloak-options'; +import { KeycloakStatusService } from './services/keycloak-status.service'; function configureAutomaticRedirectToLoginScreen(keyCloakService: KeycloakService, keycloakStatusService: KeycloakStatusService) { const keycloakInstance = keyCloakService.getKeycloakInstance(); @@ -48,10 +24,10 @@ export async function keycloakInitializer(tenant: string) { const router = inject(Router); const keycloakService = inject(KeycloakService); const keycloakStatusService = inject(KeycloakStatusService); - const baseHref = inject(BASE_HREF); + const uiRoot = inject(UI_ROOT); const config = getConfig(); - const keycloakOptions = getKeycloakOptions(baseHref, config, tenant); + const keycloakOptions = getKeycloakOptions(uiRoot, config, tenant); try { await keycloakService.init(keycloakOptions); } catch (error) { diff --git a/src/lib/tenants/keycloak-options.ts b/src/lib/tenants/keycloak-options.ts new file mode 100644 index 0000000..7f4a8a9 --- /dev/null +++ b/src/lib/tenants/keycloak-options.ts @@ -0,0 +1,27 @@ +import { KeycloakOptions } from 'keycloak-angular'; +import { IqserAppConfig } from '../utils/iqser-app-config'; + +export function getKeycloakOptions(baseUrl: string, config: IqserAppConfig, tenant: string): KeycloakOptions { + let oauthUrl = config.OAUTH_URL; + if (!oauthUrl.startsWith('http')) { + oauthUrl = oauthUrl.startsWith('/') ? oauthUrl : '/' + oauthUrl; + oauthUrl = window.location.origin + oauthUrl; + } + + return { + config: { + url: oauthUrl, + realm: tenant, + clientId: config.OAUTH_CLIENT_ID, + }, + initOptions: { + checkLoginIframe: false, + onLoad: 'check-sso', + silentCheckSsoRedirectUri: window.location.origin + baseUrl + '/assets/oauth/silent-refresh.html', + flow: 'standard', + enableLogging: true, + }, + enableBearerInterceptor: true, + loadUserProfileAtStartUp: true, + }; +} diff --git a/src/lib/tenants/services/keycloak-status.service.ts b/src/lib/tenants/services/keycloak-status.service.ts index 8b7b082..4ad9b7f 100644 --- a/src/lib/tenants/services/keycloak-status.service.ts +++ b/src/lib/tenants/services/keycloak-status.service.ts @@ -1,17 +1,26 @@ import { inject, Injectable } from '@angular/core'; -import { KeycloakService } from 'keycloak-angular'; -import { getConfig } from '../../services'; -import { TenantsService } from '../index'; -import { BASE_HREF } from '../../utils'; +import { KeycloakEventType, KeycloakService } from 'keycloak-angular'; import { NGXLogger } from 'ngx-logger'; +import { filter, switchMap } from 'rxjs/operators'; +import { getConfig } from '../../services/iqser-config.service'; +import { log, shareLast } from '../../utils'; +import { UI_ROOT } from '../../utils/tokens'; +import { getKeycloakOptions } from '../keycloak-options'; +import { TenantsService } from './tenants.service'; @Injectable({ providedIn: 'root' }) export class KeycloakStatusService { readonly #keycloakService = inject(KeycloakService); readonly #config = getConfig(); readonly #tenantsService = inject(TenantsService); - readonly #baseHref = inject(BASE_HREF); + readonly #uiRoot = inject(UI_ROOT); readonly #logger = inject(NGXLogger); + readonly token$ = this.#keycloakService.keycloakEvents$.pipe( + log('[KEYCLOAK] New event:'), + filter(event => event.type === KeycloakEventType.OnAuthSuccess || event.type === KeycloakEventType.OnAuthRefreshSuccess), + switchMap(() => this.#keycloakService.getToken()), + shareLast(), + ); createLoginUrlAndExecute(username?: string | null) { const keycloakInstance = this.#keycloakService?.getKeycloakInstance(); @@ -23,24 +32,41 @@ export class KeycloakStatusService { }); this.#logger.info('[KEYCLOAK] Redirect to login url: ', url); - - window.location.href = url; + window.location.assign(url); } else { this.#logger.error('[KEYCLOAK] Instance not found, redirect to tenant select'); - window.location.href = this.createLoginUrl(); + window.location.assign(window.origin); } } + async switchTenant(tenantId?: string) { + let redirectUri: string; + + if (tenantId) { + await this.#tenantsService.selectTenant(tenantId); + const newKCInstance = new KeycloakService(); + await newKCInstance.init(getKeycloakOptions(this.#uiRoot, this.#config, tenantId)); + redirectUri = newKCInstance.getKeycloakInstance().createLoginUrl({ + redirectUri: this.createLoginUrl(tenantId), + idpHint: this.#config.OAUTH_IDP_HINT, + }); + } else { + redirectUri = window.location.origin + this.#uiRoot; + } + + await this.#keycloakService.logout(redirectUri); + } + createLoginUrl(tenant?: string) { if (tenant && window.location.href.indexOf('/' + tenant + '/') > 0) { return window.location.href; } - const url = window.location.origin + this.#baseHref; + const origin = window.location.origin; if (tenant) { - return url + '/' + tenant; + return origin + '/ui/' + tenant + '/main'; } - return url; + return origin + '/ui'; } } diff --git a/src/lib/tenants/services/tenants.service.ts b/src/lib/tenants/services/tenants.service.ts index 9fdbfd0..ed331b8 100644 --- a/src/lib/tenants/services/tenants.service.ts +++ b/src/lib/tenants/services/tenants.service.ts @@ -1,11 +1,18 @@ import { inject, Injectable, signal } from '@angular/core'; import dayjs from 'dayjs'; import { NGXLogger } from 'ngx-logger'; +import { firstValueFrom, Observable, take } from 'rxjs'; +import { GenericService } from '../../services'; import { List } from '../../utils'; +import { Tenant, TenantDetails } from '../types'; +import { APPLICATION_TYPES } from '../../utils/constants'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { filter } from 'rxjs/operators'; export interface IStoredTenantId { readonly tenantId: string; readonly created: string; + readonly documine: boolean; } export type StoredTenantIds = List; @@ -13,7 +20,7 @@ export type StoredTenantIds = List; const STORED_TENANTS_KEY = 'red-stored-tenants'; @Injectable({ providedIn: 'root' }) -export class TenantsService { +export class TenantsService extends GenericService { readonly #logger = inject(NGXLogger); readonly #storageReference: Storage = { length: localStorage.length, @@ -24,24 +31,35 @@ export class TenantsService { key: localStorage.key.bind(localStorage), }; readonly #activeTenantId = signal(''); - protected readonly _serviceName: string = 'tenant-user-management'; + readonly #tenantSet = signal(false); + readonly tenantSet$ = toObservable(this.#tenantSet); + protected readonly _defaultModelPath = 'tenants'; + protected override readonly _serviceName: string = 'tenant-user-management'; get activeTenantId() { return this.#activeTenantId(); } - async selectTenant(tenantId: string) { + get activeTenant() { + return this.getStoredTenants().find(t => t.tenantId === this.activeTenantId); + } + + async selectTenant(tenantId: string): Promise { this.#mutateStorage(tenantId); this.#setActiveTenantId(tenantId); return true; } - storeTenant() { + async storeTenant() { const storedTenants = this.getStoredTenants(); const activeTenantId = this.#activeTenantId(); const existing = storedTenants.find(s => s.tenantId === activeTenantId); + + const tenant = await firstValueFrom(this.getActiveTenant()); + if (existing) { this.#logger.info('[TENANTS] Stored tenant exists: ', storedTenants); + this.#tenantSet.set(true); return; } @@ -50,8 +68,13 @@ export class TenantsService { return; } - storedTenants.push({ tenantId: activeTenantId, created: new Date().toISOString() }); + storedTenants.push({ + tenantId: activeTenantId, + created: new Date().toISOString(), + documine: tenant.applicationType === APPLICATION_TYPES.DOCUMINE, + }); this.#storageReference.setItem(STORED_TENANTS_KEY, JSON.stringify(storedTenants)); + this.#tenantSet.set(true); this.#logger.info('[TENANTS] Stored tenants: ', storedTenants); } @@ -67,13 +90,14 @@ export class TenantsService { const is90DaysOld = diff >= 90; if (is90DaysOld) { this.#logger.warn(`[TENANTS] Saved tenant ${s.tenantId} is 90 days old, delete it`); - this.removeStored(s.tenantId); continue; } validStoredTenants.push(s); } + this.#storageReference.setItem(STORED_TENANTS_KEY, JSON.stringify(validStoredTenants)); + return validStoredTenants; } @@ -95,6 +119,17 @@ export class TenantsService { this.#logger.info('[TENANTS] Stored tenants at logout: ', storedTenants); } + getActiveTenant(): Observable> { + return this._getOne([this.activeTenantId]); + } + + waitForSettingTenant(): Observable { + return this.tenantSet$.pipe( + filter(tenantSet => tenantSet), + take(1), + ); + } + #setActiveTenantId(tenantId: string) { this.#logger.info('[TENANTS] Set current tenant id: ', tenantId); this.#activeTenantId.set(tenantId); diff --git a/src/lib/tenants/tenant-select/tenant-select.component.html b/src/lib/tenants/tenant-select/tenant-select.component.html index 2916df5..b3f0fca 100644 --- a/src/lib/tenants/tenant-select/tenant-select.component.html +++ b/src/lib/tenants/tenant-select/tenant-select.component.html @@ -8,38 +8,40 @@ - -
+ @if (isLoggedOut || noRoleLogOut) { +
-
+ } -
+ @if (storedTenants.length) { +
+ } -
-
- - -
- {{ stored.tenantId }} -
- - -
- -
+ @if (storedTenants.length) { +
+ @for (stored of storedTenants; track stored) { +
+ +
+ {{ stored.tenantId }} +
+ +
+ +
+
+ }
-
+ } -
+ @if (storedTenants.length === 0) { +
+ } - + @if (storedTenants.length) {
-
+ }
diff --git a/src/lib/tenants/tenant-select/tenant-select.component.ts b/src/lib/tenants/tenant-select/tenant-select.component.ts index 4909b81..d9f4481 100644 --- a/src/lib/tenants/tenant-select/tenant-select.component.ts +++ b/src/lib/tenants/tenant-select/tenant-select.component.ts @@ -5,8 +5,9 @@ import { KeycloakService } from 'keycloak-angular'; import { NGXLogger } from 'ngx-logger'; import { LoadingService } from '../../loading'; import { getConfig } from '../../services'; -import { BASE_HREF } from '../../utils'; -import { getKeycloakOptions } from '../keycloak-initializer'; +import { selectTenantTranslations } from '../../translations/select-tenant-translations'; +import { UI_ROOT } from '../../utils'; +import { getKeycloakOptions } from '../keycloak-options'; import { IStoredTenantId, TenantsService } from '../services'; import { KeycloakStatusService } from '../services/keycloak-status.service'; @@ -14,9 +15,10 @@ import { KeycloakStatusService } from '../services/keycloak-status.service'; templateUrl: './tenant-select.component.html', styleUrls: ['./tenant-select.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, }) export class TenantSelectComponent { - protected readonly baseHref = inject(BASE_HREF); + readonly #uiRoot = inject(UI_ROOT); protected readonly logger = inject(NGXLogger); protected readonly tenantsService = inject(TenantsService); protected storedTenants: IStoredTenantId[] = []; @@ -29,7 +31,9 @@ export class TenantSelectComponent { // eslint-disable-next-line @typescript-eslint/unbound-method tenantId: ['', Validators.required], }); + protected readonly translations = selectTenantTranslations; @Input() isLoggedOut = false; + @Input() noRoleLogOut = false; constructor() { this.#loadStoredTenants(); @@ -48,7 +52,7 @@ export class TenantSelectComponent { async select(tenantId: string) { try { this.logger.info('[KEYCLOAK] Initializing keycloak for tenant', tenantId); - await this.keycloakService.init(getKeycloakOptions(this.baseHref, this.config, tenantId)); + await this.keycloakService.init(getKeycloakOptions(this.#uiRoot, this.config, tenantId)); } catch (e) { this.logger.info('[KEYCLOAK] Init failed. Logout'); return this.keycloakService.logout(); @@ -67,6 +71,10 @@ export class TenantSelectComponent { this.#loadStoredTenants(); } + protected tenantIcon(tenant: IStoredTenantId) { + return `red:${tenant.documine ? 'documine' : 'redaction'}-logo`; + } + #loadStoredTenants() { this.storedTenants = this.tenantsService.getStoredTenants().sort((a, b) => a.tenantId.localeCompare(b.tenantId)); } diff --git a/src/lib/tenants/tenant.pipe.ts b/src/lib/tenants/tenant.pipe.ts deleted file mode 100644 index 8b581b7..0000000 --- a/src/lib/tenants/tenant.pipe.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { inject, Pipe, PipeTransform } from '@angular/core'; -import { TenantsService } from './services'; - -@Pipe({ - name: 'tenant', - pure: true, - standalone: true, -}) -export class TenantPipe implements PipeTransform { - readonly #tenantsService = inject(TenantsService); - - transform(value: string | string[]): string | undefined { - if (!value) { - return undefined; - } - const _value = Array.isArray(value) ? value.join('/') : value; - return '/' + this.#tenantsService.activeTenantId + _value; - } -} diff --git a/src/lib/tenants/tenants.module.ts b/src/lib/tenants/tenants.module.ts index 104c679..dcbbe46 100644 --- a/src/lib/tenants/tenants.module.ts +++ b/src/lib/tenants/tenants.module.ts @@ -10,11 +10,13 @@ import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { RouterLink } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; -import { CircleButtonComponent, IconButtonComponent } from '../buttons'; -import { StopPropagationDirective } from '../directives'; -import { LogoComponent } from '../shared'; +import { CircleButtonComponent } from '../buttons/circle-button/circle-button.component'; +import { IconButtonComponent } from '../buttons/icon-button/icon-button.component'; +import { StopPropagationDirective } from '../directives/stop-propagation.directive'; +import { LogoComponent } from '../shared/logo/logo.component'; import { SpacerComponent } from '../shared/spacer/spacer.component'; -import { TenantIdInterceptor, TenantIdResponseInterceptor } from './services'; +import { TenantIdInterceptor } from './services/tenant-id-interceptor'; +import { TenantIdResponseInterceptor } from './services/tenant-id-response-interceptor'; import { TenantSelectComponent } from './tenant-select/tenant-select.component'; @NgModule({ diff --git a/src/lib/tenants/types/index.ts b/src/lib/tenants/types/index.ts new file mode 100644 index 0000000..94a1c2b --- /dev/null +++ b/src/lib/tenants/types/index.ts @@ -0,0 +1 @@ +export * from './tenant'; diff --git a/src/lib/tenants/types/tenant.ts b/src/lib/tenants/types/tenant.ts new file mode 100644 index 0000000..41696c6 --- /dev/null +++ b/src/lib/tenants/types/tenant.ts @@ -0,0 +1,10 @@ +import { ApplicationType } from '../../utils/constants'; + +export type TenantDetails = Record; + +export interface Tenant { + tenantId: string; + displayName: string; + applicationType: ApplicationType; + details: TD; +} diff --git a/src/lib/translations/http-loader-factory.ts b/src/lib/translations/http-loader-factory.ts index 442d7c1..73cd608 100644 --- a/src/lib/translations/http-loader-factory.ts +++ b/src/lib/translations/http-loader-factory.ts @@ -1,12 +1,14 @@ import { HttpClient } from '@angular/common/http'; -import { PruningTranslationLoader } from '../utils'; -import { getConfig, IqserConfigService } from '../services'; import { inject } from '@angular/core'; +import { getConfig } from '../services'; +import { PruningTranslationLoader } from '../utils'; +import { TenantsService } from '../tenants'; export function pruningTranslationLoaderFactory(pathPrefix: string): PruningTranslationLoader { const httpClient = inject(HttpClient); + const tenantService = inject(TenantsService); const config = getConfig(); const version = config.FRONTEND_APP_VERSION; - return new PruningTranslationLoader(httpClient, pathPrefix, `.json?version=${version}`); + return new PruningTranslationLoader(httpClient, tenantService, pathPrefix, `.json?version=${version}`); } diff --git a/src/lib/translations/iqser-translate-parser.service.ts b/src/lib/translations/iqser-translate-parser.service.ts index 973f5de..949fe32 100644 --- a/src/lib/translations/iqser-translate-parser.service.ts +++ b/src/lib/translations/iqser-translate-parser.service.ts @@ -4,7 +4,7 @@ import { escapeHtml } from '../utils'; @Injectable() export class IqserTranslateParser extends TranslateDefaultParser { - interpolate(expr: any, params?: Record) { + override interpolate(expr: any, params?: Record) { const entries = Object.entries(params ?? {}); const escapedParams = entries.reduce((acc, [key, value]) => ({ ...acc, [key]: escapeHtml(value) }), {}); return super.interpolate(expr, escapedParams); diff --git a/src/lib/translations/iqser-translate.module.ts b/src/lib/translations/iqser-translate.module.ts index 2023cde..23429b0 100644 --- a/src/lib/translations/iqser-translate.module.ts +++ b/src/lib/translations/iqser-translate.module.ts @@ -1,16 +1,17 @@ +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { inject, InjectionToken, ModuleWithProviders, NgModule } from '@angular/core'; -import { TranslateCompiler, TranslateLoader, TranslateModule, TranslateParser } from '@ngx-translate/core'; +import { MissingTranslationHandler, TranslateCompiler, TranslateLoader, TranslateModule, TranslateParser } from '@ngx-translate/core'; import { TranslateMessageFormatCompiler } from 'ngx-translate-messageformat-compiler'; import { pruningTranslationLoaderFactory } from './http-loader-factory'; import { IqserTranslateModuleOptions } from './iqser-translate-module-options'; import { IqserTranslateParser } from './iqser-translate-parser.service'; -import { HttpClientModule } from '@angular/common/http'; +import { IqserMissingTranslationHandler } from './missing-translations-handler'; const translateLoaderToken = new InjectionToken('translateLoader'); @NgModule({ + exports: [TranslateModule], imports: [ - HttpClientModule, TranslateModule.forRoot({ loader: { provide: TranslateLoader, @@ -26,7 +27,7 @@ const translateLoaderToken = new InjectionToken('translateLoader'); }, }), ], - exports: [TranslateModule], + providers: [provideHttpClient(withInterceptorsFromDi())], }) export class IqserTranslateModule { constructor() { @@ -46,6 +47,10 @@ export class IqserTranslateModule { provide: translateLoaderToken, useFactory: () => pruningTranslationLoaderFactory(pathPrefix), }, + { + provide: MissingTranslationHandler, + useClass: IqserMissingTranslationHandler, + }, ], }; } diff --git a/src/lib/translations/missing-translations-handler.ts b/src/lib/translations/missing-translations-handler.ts new file mode 100644 index 0000000..e78c45e --- /dev/null +++ b/src/lib/translations/missing-translations-handler.ts @@ -0,0 +1,8 @@ +import { MissingTranslationHandler, MissingTranslationHandlerParams } from '@ngx-translate/core'; + +export class IqserMissingTranslationHandler implements MissingTranslationHandler { + handle(params: MissingTranslationHandlerParams) { + const missingKey = params.key; + return `?${missingKey}?`; + } +} diff --git a/src/lib/translations/pruning-translation-loader.ts b/src/lib/translations/pruning-translation-loader.ts index b9c76f7..64b49f5 100644 --- a/src/lib/translations/pruning-translation-loader.ts +++ b/src/lib/translations/pruning-translation-loader.ts @@ -1,21 +1,42 @@ import { HttpClient } from '@angular/common/http'; import { TranslateLoader } from '@ngx-translate/core'; -import { map } from 'rxjs/operators'; +import { map, switchMap } from 'rxjs/operators'; import { Observable } from 'rxjs'; +import { TenantsService } from '../tenants'; +import { APP_TYPE_PATHS } from '../utils/constants'; +import { inject } from '@angular/core'; +import { GET_TENANT_FROM_PATH_FN } from '../utils'; interface T { [key: string]: string | T; } export class PruningTranslationLoader implements TranslateLoader { + readonly tenant = inject(GET_TENANT_FROM_PATH_FN)(); constructor( private readonly _http: HttpClient, + private readonly _tenantService: TenantsService, private readonly _prefix: string, private readonly _suffix: string, ) {} getTranslation(lang: string): Observable { - return this._http.get(`${this._prefix}${lang}${this._suffix}`).pipe(map(result => this._process(result as T))); + if (this.tenant) { + return this._tenantService.waitForSettingTenant().pipe( + switchMap(() => { + const tenant = this._tenantService.activeTenant; + const translationPath = tenant?.documine ? APP_TYPE_PATHS.SCM : APP_TYPE_PATHS.REDACT; + + return this._http + .get(`${this._prefix}${translationPath}/${lang}${this._suffix}`) + .pipe(map(result => this._process(result as T))); + }), + ); + } + + return this._http + .get(`${this._prefix}${APP_TYPE_PATHS.REDACT}/${lang}${this._suffix}`) + .pipe(map(result => this._process(result as T))); } private _process(object: T): T { diff --git a/src/lib/translations/select-tenant-translations.ts b/src/lib/translations/select-tenant-translations.ts new file mode 100644 index 0000000..506b305 --- /dev/null +++ b/src/lib/translations/select-tenant-translations.ts @@ -0,0 +1,6 @@ +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; + +export const selectTenantTranslations: { [key in string]: string } = { + IS_LOGGED_OUT: _('tenant-resolve.header.youre-logged-out'), + NO_ROLE_LOG_OUT: _('tenant-resolve.header.no-role-log-out'), +} as const; diff --git a/src/lib/upload-file/index.ts b/src/lib/upload-file/index.ts index 2eeb4b4..3889b3c 100644 --- a/src/lib/upload-file/index.ts +++ b/src/lib/upload-file/index.ts @@ -1,3 +1,2 @@ export * from './drag-drop-file-upload.directive'; export * from './upload-file.component'; -export * from './upload-file.module'; diff --git a/src/lib/upload-file/upload-file.component.html b/src/lib/upload-file/upload-file.component.html index 4dd42fb..617ff32 100644 --- a/src/lib/upload-file/upload-file.component.html +++ b/src/lib/upload-file/upload-file.component.html @@ -1,24 +1,25 @@ -
-
+@if (!file) { +
-
-
+} +@if (file) { +
- -

{{ file.name }}

- - +

{{ file.name }}

+ @if (!readonly) { + + }
+} - -
+ diff --git a/src/lib/upload-file/upload-file.component.scss b/src/lib/upload-file/upload-file.component.scss deleted file mode 100644 index 83d7b43..0000000 --- a/src/lib/upload-file/upload-file.component.scss +++ /dev/null @@ -1,58 +0,0 @@ -.upload-area, -.file-area { - display: flex; - align-items: center; - border-radius: 8px; - width: 100%; - box-sizing: border-box; - background: var(--iqser-alt-background); - - &.drag-over { - background-color: var(--iqser-file-drop-drag-over); - } -} - -.upload-area { - gap: 16px; - height: 88px; - cursor: pointer; - padding: 0 32px; - - mat-icon, - div { - opacity: 0.5; - transition: 0.1s; - } - - div { - font-size: 16px; - font-weight: 500; - } -} - -.file-area { - gap: 10px; - height: 48px; - - mat-icon:first-child { - opacity: 0.5; - margin-left: 16px; - } - - mat-icon:last-child { - margin-left: auto; - margin-right: 16px; - cursor: pointer; - } - - mat-icon { - transform: scale(0.7); - } - - p { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - max-width: 490px; - } -} diff --git a/src/lib/upload-file/upload-file.component.ts b/src/lib/upload-file/upload-file.component.ts index 864b0f7..64d405e 100644 --- a/src/lib/upload-file/upload-file.component.ts +++ b/src/lib/upload-file/upload-file.component.ts @@ -1,9 +1,14 @@ -import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; +import { DragDropFileUploadDirective } from './drag-drop-file-upload.directive'; +import { MatIcon } from '@angular/material/icon'; +import { TranslateModule } from '@ngx-translate/core'; @Component({ selector: 'iqser-upload-file', templateUrl: './upload-file.component.html', - styleUrls: ['./upload-file.component.scss'], + host: { '[class.iqser-upload-file]': 'true' }, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DragDropFileUploadDirective, MatIcon, TranslateModule], }) export class UploadFileComponent { @ViewChild('attachFileInput', { static: true }) attachFileInput!: ElementRef; @@ -17,12 +22,13 @@ export class UploadFileComponent { this.attachFileInput.nativeElement.click(); } - attachFile(event: any) { - const files = event?.target?.files; + attachFile(event: Event) { + const target = event.target as HTMLInputElement; + const files = target?.files || []; this.file = files[0]; // input field needs to be set as empty in case the same file will be selected second time - event.target.value = ''; + target.value = ''; if (!this.file) { console.error('No file to import!'); diff --git a/src/lib/upload-file/upload-file.module.ts b/src/lib/upload-file/upload-file.module.ts deleted file mode 100644 index d2665ac..0000000 --- a/src/lib/upload-file/upload-file.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NgModule } from '@angular/core'; -import { UploadFileComponent } from './upload-file.component'; -import { DragDropFileUploadDirective } from './drag-drop-file-upload.directive'; -import { MatIconModule } from '@angular/material/icon'; -import { CommonModule } from '@angular/common'; -import { TranslateModule } from '@ngx-translate/core'; - -const components = [UploadFileComponent, DragDropFileUploadDirective]; - -@NgModule({ - declarations: [...components], - exports: [...components], - imports: [MatIconModule, CommonModule, TranslateModule], -}) -export class IqserUploadFileModule {} diff --git a/src/lib/users/components/initials-avatar/initials-avatar.component.html b/src/lib/users/components/initials-avatar/initials-avatar.component.html index 75bf3ae..aee6b34 100644 --- a/src/lib/users/components/initials-avatar/initials-avatar.component.html +++ b/src/lib/users/components/initials-avatar/initials-avatar.component.html @@ -1,13 +1,16 @@ -
-
- {{ _user | name: { showInitials: true } }} +@if (_user() && _user() | name: namePipeOptions(); as userName) { +
+
+ {{ _user() | name: { showInitials: true } }} +
+ @if (withName()) { +
+ {{ userName }} +
+ }
- -
- {{ userName }} -
-
+} diff --git a/src/lib/users/components/initials-avatar/initials-avatar.component.ts b/src/lib/users/components/initials-avatar/initials-avatar.component.ts index 892781e..24dfe68 100644 --- a/src/lib/users/components/initials-avatar/initials-avatar.component.ts +++ b/src/lib/users/components/initials-avatar/initials-avatar.component.ts @@ -1,8 +1,11 @@ -import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatTooltip } from '@angular/material/tooltip'; import { TranslateService } from '@ngx-translate/core'; +import { IqserUser } from '../../iqser-user.model'; +import { NamePipe } from '../../name.pipe'; import { IqserUserService } from '../../services/iqser-user.service'; import { NamePipeOptions } from '../../types/name-pipe-options'; -import { IqserUser } from '../../iqser-user.model'; import { IIqserUser } from '../../types/user.response'; @Component({ @@ -10,83 +13,45 @@ import { IIqserUser } from '../../types/user.response'; templateUrl: './initials-avatar.component.html', styleUrls: ['./initials-avatar.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NamePipe, MatTooltip], }) -export class InitialsAvatarComponent - implements OnInit, OnChanges -{ - @Input() color = 'lightgray'; - @Input() size: 'small' | 'large' = 'small'; - @Input() withName = false; - @Input() showYou = false; - @Input() tooltipPosition: 'below' | 'above' = 'above'; - @Input() defaultValue: string = this._translateService.instant('initials-avatar.unassigned'); - @Input() showTooltip = true; - colorClass?: string; - namePipeOptions?: NamePipeOptions; - - constructor( - private readonly _userService: IqserUserService, - private readonly _translateService: TranslateService, - ) {} - - _user?: Class; - - @Input() - set user(user: Class | string) { +export class InitialsAvatarComponent< + Interface extends IIqserUser = IIqserUser, + Class extends IqserUser & Interface = IqserUser & Interface, +> { + readonly #userService = inject>(IqserUserService); + readonly #translateService = inject(TranslateService); + readonly #users = toSignal(this.#userService.all$); + readonly color = input('lightgray'); + readonly size = input<'small' | 'large' | 'extra-small'>('small'); + readonly withName = input(false); + readonly showYou = input(false); + readonly tooltipPosition = input<'below' | 'above'>('above'); + readonly defaultValue = input(this.#translateService.instant('initials-avatar.unassigned')); + readonly showTooltip = input(true); + readonly user = input.required(); + readonly showBorderCondition = input<(user: T) => boolean>(user => user.isSpecial); + readonly _user = computed(() => { + const user = this.user(); if (typeof user === 'string') { - this._user = this._userService.find(user); - } else { - this._user = user; + if (user?.toLowerCase() === 'system') return this.#userService.newSystemUser(); + if (!user) return undefined; + return this.#users()?.find(u => u.id === user) ?? this.#userService.newDeletedUser(); } - } - - get hasBorder(): boolean { - return !!this._user && !this.isCurrentUser && this.showBorderCondition(this._user); - } - - get disabled(): boolean { - return !!this._user && !this._isSystemUser && !this._user.hasAnyRole; - } - - get isCurrentUser(): boolean { - return this._userService.currentUser?.id === this._user?.id; - } - - private get _colorClass() { - if (this.isCurrentUser) { - return 'primary-white'; - } - - if (this.disabled) { - return 'inactive'; - } - - if (this.color.includes('-')) { - return this.color; - } - - return `${this.color}-dark`; - } - - private get _isSystemUser() { - return this._user?.id?.toLowerCase() === 'system'; - } - - @Input() showBorderCondition: (user: T) => boolean = user => user.isSpecial; - - ngOnChanges(): void { - if (this._isSystemUser) { - this.colorClass = 'primary-white primary'; - return; - } - - this.colorClass = this._colorClass; - } - - ngOnInit(): void { - this.namePipeOptions = { - showYou: this.showYou, - defaultValue: this.defaultValue, - }; - } + return user; + }); + readonly #isSystemUser = computed(() => this._user()?.id?.toLowerCase() === 'system'); + readonly isCurrentUser = computed(() => this.#userService.currentUser?.id === this._user()?.id); + readonly hasBorder = computed(() => !!this._user() && !this.isCurrentUser() && this.showBorderCondition()(this._user()!)); + readonly disabled = computed(() => !!this._user() && !this.#isSystemUser() && !this._user()?.hasAnyRole); + readonly colorClass = computed(() => { + if (this.isCurrentUser()) return 'primary-white'; + if (this.disabled()) return 'inactive'; + if (this.color().includes('-')) return this.color(); + if (this.#isSystemUser()) return 'primary-white primary'; + return `${this.color()}-dark`; + }); + readonly namePipeOptions = computed( + () => ({ showYou: this.showYou(), defaultValue: this.defaultValue() }) as NamePipeOptions, + ); } diff --git a/src/lib/users/components/user-button/user-button.component.html b/src/lib/users/components/user-button/user-button.component.html index 694c628..8d4e57b 100644 --- a/src/lib/users/components/user-button/user-button.component.html +++ b/src/lib/users/components/user-button/user-button.component.html @@ -1,14 +1,18 @@ -
- - - - +@if (showDot) { +
+} diff --git a/src/lib/users/components/user-button/user-button.component.ts b/src/lib/users/components/user-button/user-button.component.ts index d567d95..50f707d 100644 --- a/src/lib/users/components/user-button/user-button.component.ts +++ b/src/lib/users/components/user-button/user-button.component.ts @@ -6,6 +6,7 @@ import { IqserUserService } from '../../services/iqser-user.service'; templateUrl: './user-button.component.html', styleUrls: ['./user-button.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false, }) export class UserButtonComponent { @Input() showDot = false; diff --git a/src/lib/users/guards/has-roles.guard.ts b/src/lib/users/guards/has-roles.guard.ts deleted file mode 100644 index 53227eb..0000000 --- a/src/lib/users/guards/has-roles.guard.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { CanActivateFn, Router } from '@angular/router'; -import { inject } from '@angular/core'; -import { IqserUserService } from '../services/iqser-user.service'; -import { TenantsService } from '../../tenants'; - -export function hasAnyRoleGuard(): CanActivateFn { - return async () => { - const router = inject(Router); - const activeTenantId = inject(TenantsService).activeTenantId; - const user = await inject(IqserUserService).loadCurrentUser(); - if (user?.hasAnyRole) { - await router.navigate([`/${activeTenantId}/main`]); - return false; - } - return true; - }; -} diff --git a/src/lib/users/guards/iqser-role-guard.service.ts b/src/lib/users/guards/iqser-role-guard.service.ts index 0c071bc..7879b89 100644 --- a/src/lib/users/guards/iqser-role-guard.service.ts +++ b/src/lib/users/guards/iqser-role-guard.service.ts @@ -2,19 +2,18 @@ import { inject, Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; import { LoadingService } from '../../loading'; import { IqserUserService } from '../services/iqser-user.service'; -import { TenantsService } from '../../tenants'; @Injectable() export class IqserRoleGuard implements CanActivate { protected readonly _router = inject(Router); - protected readonly _tenantsService = inject(TenantsService); protected readonly _loadingService = inject(LoadingService); protected readonly _userService = inject(IqserUserService); - async canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async canActivate(route: ActivatedRouteSnapshot, _: RouterStateSnapshot) { const currentUser = this._userService.currentUser; if (!currentUser || !currentUser.hasAnyRole) { - await this._router.navigate([`/${this._tenantsService.activeTenantId}/auth-error`]); + await this._router.navigate(['/auth-error']); this._loadingService.stop(); return false; } diff --git a/src/lib/users/guards/roles.guard.ts b/src/lib/users/guards/roles.guard.ts new file mode 100644 index 0000000..fcfda12 --- /dev/null +++ b/src/lib/users/guards/roles.guard.ts @@ -0,0 +1,29 @@ +import { Router } from '@angular/router'; +import { inject } from '@angular/core'; +import { IqserUserService } from '../services/iqser-user.service'; +import { AsyncGuard } from '../../services'; + +export function doesNotHaveAnyRole(): AsyncGuard { + return async () => { + const router = inject(Router); + const user = await inject(IqserUserService).loadCurrentUser(); + if (user?.hasAnyRole) { + await router.navigate(['main']); + return false; + } + return true; + }; +} + +export function hasAnyRole(): AsyncGuard { + return async () => { + const userService = inject(IqserUserService); + const user = await userService.loadCurrentUser(); + + if (!user?.hasAnyRole) { + await userService.logout(true); + return false; + } + return true; + }; +} diff --git a/src/lib/users/index.ts b/src/lib/users/index.ts index 744b89f..be19ace 100644 --- a/src/lib/users/index.ts +++ b/src/lib/users/index.ts @@ -11,6 +11,6 @@ export * from './services/default-user.service'; export * from './iqser-users.module'; export * from './guards/iqser-auth-guard.service'; export * from './guards/iqser-role-guard.service'; -export * from './guards/has-roles.guard'; +export * from './guards/roles.guard'; export * from './components/user-button/user-button.component'; export * from './components/initials-avatar/initials-avatar.component'; diff --git a/src/lib/users/iqser-users.module.ts b/src/lib/users/iqser-users.module.ts index 513c54f..95c3464 100644 --- a/src/lib/users/iqser-users.module.ts +++ b/src/lib/users/iqser-users.module.ts @@ -18,10 +18,19 @@ import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; import { TranslateModule } from '@ngx-translate/core'; -const components = [NamePipe, InitialsAvatarComponent, UserButtonComponent]; +const components = [UserButtonComponent]; @NgModule({ - imports: [KeycloakAngularModule, MatTooltipModule, CommonModule, MatIconModule, MatButtonModule, TranslateModule], + imports: [ + KeycloakAngularModule, + MatTooltipModule, + CommonModule, + MatIconModule, + MatButtonModule, + TranslateModule, + InitialsAvatarComponent, + NamePipe, + ], declarations: [...components], exports: [...components], }) diff --git a/src/lib/users/name.pipe.ts b/src/lib/users/name.pipe.ts index c48cf83..22e302a 100644 --- a/src/lib/users/name.pipe.ts +++ b/src/lib/users/name.pipe.ts @@ -1,8 +1,9 @@ -import { Pipe, PipeTransform } from '@angular/core'; +import { inject, Pipe, PipeTransform } from '@angular/core'; +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; import { TranslateService } from '@ngx-translate/core'; +import { IqserUser } from './iqser-user.model'; import { IqserUserService } from './services/iqser-user.service'; import { NamePipeOptions } from './types/name-pipe-options'; -import { IqserUser } from './iqser-user.model'; function getInitials(name: string) { if (name.toLowerCase() === 'system') { @@ -20,16 +21,14 @@ function getInitials(name: string) { name: 'name', }) export class NamePipe implements PipeTransform { + readonly #translateService = inject(TranslateService); protected readonly _defaultOptions: Required = { - defaultValue: this._translateService.instant('unknown') as string, + defaultValue: this.#translateService.instant('unknown') as string, showYou: false, showInitials: false, }; - constructor( - private readonly _userService: IqserUserService, - private readonly _translateService: TranslateService, - ) {} + constructor(private readonly _userService: IqserUserService) {} transform(value: IqserUser | string, options: NamePipeOptions = this._defaultOptions): string { if (!value || value === 'undefined') { @@ -43,7 +42,7 @@ export class NamePipe implements PipeTransform { } if (options.showYou && this._isCurrentUser(value)) { - name = `${name} (${this._translateService.instant('initials-avatar.you')})`; + name = `${name} (${this.#translateService.instant(_('initials-avatar.you'))})`; } return options.showInitials ? getInitials(name) : name; diff --git a/src/lib/users/services/iqser-user.service.ts b/src/lib/users/services/iqser-user.service.ts index 3672efc..555abe6 100644 --- a/src/lib/users/services/iqser-user.service.ts +++ b/src/lib/users/services/iqser-user.service.ts @@ -1,33 +1,33 @@ +import { HttpErrorResponse, HttpStatusCode } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; +import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; import { KeycloakService } from 'keycloak-angular'; +import { KeycloakProfile } from 'keycloak-js'; import { BehaviorSubject, firstValueFrom, Observable, throwError } from 'rxjs'; import { catchError, tap } from 'rxjs/operators'; -import { BASE_HREF, List, mapEach } from '../../utils'; -import { QueryParam, Toaster } from '../../services'; import { CacheApiService } from '../../caching'; import { EntitiesService } from '../../listing'; -import { IIqserUser } from '../types/user.response'; +import { IqserPermissionsService, IqserRolesService } from '../../permissions'; +import { QueryParam, Toaster } from '../../services'; +import { KeycloakStatusService } from '../../tenants'; +import { List, mapEach, UI_ROOT } from '../../utils'; +import { IqserUser } from '../iqser-user.model'; import { ICreateUserRequest } from '../types/create-user.request'; -import { IResetPasswordRequest } from '../types/reset-password.request'; import { IMyProfileUpdateRequest } from '../types/my-profile-update.request'; import { IProfileUpdateRequest } from '../types/profile-update.request'; -import { KeycloakProfile } from 'keycloak-js'; -import { IqserUser } from '../iqser-user.model'; -import { IqserPermissionsService, IqserRolesService } from '../../permissions'; -import { HttpErrorResponse, HttpStatusCode } from '@angular/common/http'; -import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; -import { KeycloakStatusService } from '../../tenants'; +import { IResetPasswordRequest } from '../types/reset-password.request'; +import { IIqserUser } from '../types/user.response'; @Injectable() export abstract class IqserUserService< Interface extends IIqserUser = IIqserUser, Class extends IqserUser & Interface = IqserUser & Interface, > extends EntitiesService { - readonly currentUser$: Observable; - protected abstract readonly _defaultModelPath: string; + readonly #uiRoot = inject(UI_ROOT); + protected abstract override readonly _defaultModelPath: string; protected abstract readonly _permissionsFilter: (role: string) => boolean; protected abstract readonly _rolesFilter: (role: string) => boolean; - protected abstract readonly _entityClass: new (entityInterface: Interface | KeycloakProfile, ...args: unknown[]) => Class; + protected abstract override readonly _entityClass: new (entityInterface: Interface | KeycloakProfile, ...args: unknown[]) => Class; protected readonly _currentUser$ = new BehaviorSubject(undefined); protected readonly _toaster = inject(Toaster); protected readonly _keycloakService = inject(KeycloakService); @@ -35,8 +35,8 @@ export abstract class IqserUserService< protected readonly _keycloakStatusService = inject(KeycloakStatusService); protected readonly _permissionsService = inject(IqserPermissionsService, { optional: true }); protected readonly _rolesService = inject(IqserRolesService, { optional: true }); - protected readonly _baseHref = inject(BASE_HREF); - protected readonly _serviceName: string = 'tenant-user-management'; + protected override readonly _serviceName: string = 'tenant-user-management'; + readonly currentUser$: Observable; constructor() { super(); @@ -58,11 +58,12 @@ export abstract class IqserUserService< await firstValueFrom(this.loadAll()); } - async logout() { + async logout(noRoleLogOut = false) { try { await this._keycloakService.loadUserProfile(true); await this._cacheApiService.wipeCaches(); - const redirectUri = window.location.origin + this._baseHref + '/?isLoggedOut=true'; + const logoutParam = noRoleLogOut ? 'noRoleLogOut' : 'isLoggedOut'; + const redirectUri = window.location.origin + this.#uiRoot + `/?${logoutParam}=true`; await this._keycloakService.logout(redirectUri); } catch (e) { console.log('Logout failed: ', e); @@ -75,7 +76,11 @@ export abstract class IqserUserService< this._keycloakStatusService.createLoginUrlAndExecute(); } - loadAll() { + async createResetPasswordAction() { + await this._keycloakService.login({ action: 'UPDATE_PASSWORD' }); + } + + override loadAll() { return this.getAll().pipe( mapEach(user => new this._entityClass(user, user.roles, user.userId)), tap(users => this.setEntities(users)), @@ -116,7 +121,7 @@ export abstract class IqserUserService< return this.find(userId)?.name; } - getAll(url = this._defaultModelPath): Observable { + override getAll(url = this._defaultModelPath): Observable { return super.getAll(url, [{ key: 'refreshCache', value: true }]); } @@ -152,21 +157,29 @@ export abstract class IqserUserService< return this._post(body); } - delete(userIds: List) { + override delete(userIds: List) { const queryParams = userIds.map(userId => ({ key: 'userId', value: userId })); return super.delete(userIds, this._defaultModelPath, queryParams); } - find(id: string): Class | undefined { + override find(id: string): Class | undefined { if (id?.toLowerCase() === 'system') { - return new this._entityClass({ username: 'System' }, [], 'system'); + return this.newSystemUser(); } if (!id) { return undefined; } - return super.find(id) ?? new this._entityClass({ username: 'Deleted User' }, [], 'deleted'); + return super.find(id) ?? this.newDeletedUser(); + } + + newSystemUser() { + return new this._entityClass({ username: 'System' }, [], 'system'); + } + + newDeletedUser() { + return new this._entityClass({ username: 'Deleted User' }, [], 'deleted'); } } diff --git a/src/lib/users/types/create-user.request.ts b/src/lib/users/types/create-user.request.ts index e03a148..ef52d88 100644 --- a/src/lib/users/types/create-user.request.ts +++ b/src/lib/users/types/create-user.request.ts @@ -5,4 +5,5 @@ export interface ICreateUserRequest { firstName?: string; lastName?: string; roles?: List; + sendSetPasswordMail?: boolean; } diff --git a/src/lib/utils/auto-unsubscribe.directive.ts b/src/lib/utils/auto-unsubscribe.directive.ts index 1431dc3..cb3fa31 100644 --- a/src/lib/utils/auto-unsubscribe.directive.ts +++ b/src/lib/utils/auto-unsubscribe.directive.ts @@ -3,7 +3,8 @@ import { Subscription } from 'rxjs'; import { OnDetach } from './custom-route-reuse.strategy'; /** - * Inherit this class when you need to subscribe to observables in your components + * @deprecated Use takeUntilDestroyed() + * TODO: remove this asap */ @Directive() export abstract class AutoUnsubscribe implements OnDestroy, OnDetach { diff --git a/src/lib/utils/constants.ts b/src/lib/utils/constants.ts index 1fb4ef8..9fbe9d4 100644 --- a/src/lib/utils/constants.ts +++ b/src/lib/utils/constants.ts @@ -5,15 +5,21 @@ export const ICONS = new Set([ 'arrow-right', 'calendar', 'check', + 'chevron-down', + 'chevron-up', 'close', 'collapse', + 'color-picker', 'copy', 'csv', 'document', 'download', 'edit', + 'exit-fullscreen', 'expand', 'failure', + 'filter-list', + 'fullscreen', 'help-outline', 'lanes', 'list', @@ -31,12 +37,45 @@ export const ICONS = new Set([ 'radio-indeterminate', 'radio-selected', 'refresh', + 'resize', 'search', 'settings', 'sort-asc', 'sort-desc', 'status-collapse', 'status-expand', + 'thumb-up', + 'thumb-down', 'trash', 'upload', + 'visibility', + 'visibility-off', ]); + +export const LANDING_PAGE_THEMES = { + REDACT_MANAGER: 'redactmanager', + DOCUMINE: 'documine', + MIXED: 'mixed', +} as const; + +export const THEME_DIRECTORIES = { + REDACT: 'redact', + SCM: 'scm', +} as const; + +export const APP_TYPE_PATHS = { + REDACT: 'redact', + SCM: 'scm', +} as const; + +export const APPLICATION_TYPES = { + REDACT_MANAGER: 'RedactManager', + DOCUMINE: 'DocuMine', +} as const; + +export type ApplicationType = (typeof APPLICATION_TYPES)[keyof typeof APPLICATION_TYPES]; + +export const MANUAL_BASE_URL = { + REDACT_MANAGER: 'https://docs.redactmanager.com/preview', + DOCUMINE: 'https://docs.documine.ai/1.2', +}; diff --git a/src/lib/utils/context.component.ts b/src/lib/utils/context.component.ts index cd89a04..9100766 100644 --- a/src/lib/utils/context.component.ts +++ b/src/lib/utils/context.component.ts @@ -2,6 +2,9 @@ import { combineLatest, Observable, of } from 'rxjs'; import { map, startWith } from 'rxjs/operators'; import { ValuesOf } from './types/utility-types'; +/** + * @deprecated Switch to signals instead + */ export class ContextComponent { componentContext$: Observable | null = of({} as T); diff --git a/src/lib/utils/custom-route-reuse.strategy.ts b/src/lib/utils/custom-route-reuse.strategy.ts index 00d69b7..b993da2 100644 --- a/src/lib/utils/custom-route-reuse.strategy.ts +++ b/src/lib/utils/custom-route-reuse.strategy.ts @@ -1,7 +1,7 @@ -import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router'; -import { Debounce } from '../utils'; import { ComponentRef, Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router'; import { Subject } from 'rxjs'; +import { Debounce } from './decorators/debounce.decorator'; export interface OnAttach { ngOnAttach(previousRoute?: ActivatedRouteSnapshot): void; @@ -25,9 +25,9 @@ interface RouteStorageObject { @Injectable({ providedIn: 'root' }) export class CustomRouteReuseStrategy implements RouteReuseStrategy { + readonly #storedRoutes = new Map(); readonly attached$ = new Subject(); readonly detached$ = new Subject(); - readonly #storedRoutes = new Map(); private static _removeTooltips(): void { while (document.getElementsByTagName('mat-tooltip-component').length > 0) { diff --git a/src/lib/utils/functions.ts b/src/lib/utils/functions.ts index da8ca29..da533ac 100644 --- a/src/lib/utils/functions.ts +++ b/src/lib/utils/functions.ts @@ -1,9 +1,10 @@ import { inject } from '@angular/core'; -import { UntypedFormGroup } from '@angular/forms'; +import { AbstractControl, UntypedFormGroup } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import dayjs, { type Dayjs } from 'dayjs'; import { forOwn, has, isEqual, isPlainObject, transform } from 'lodash-es'; -import type { Id, ITrackable } from '../listing'; +import { Id, ITrackable } from '../listing/models/trackable'; +import { toSignal } from '@angular/core/rxjs-interop'; export function capitalize(value: string | string): string { if (!value) { @@ -141,12 +142,16 @@ export function trackByFactory, PrimaryKey exte return (_index: number, item: T): Id => item.id; } -export function hasFormChanged(form: UntypedFormGroup, initialFormValue: Record): boolean { +export function hasFormChanged(form: UntypedFormGroup, initialFormValue: Record, ignoredKeys: string[] = []): boolean { if (!form || !initialFormValue) { return false; } for (const key of Object.keys(form.getRawValue())) { + if (ignoredKeys.includes(key)) { + continue; + } + const initialValue = initialFormValue[key]; const updatedValue = form.get(key)?.value; @@ -314,3 +319,23 @@ export function getParam(param: string, route = inject(ActivatedRoute)): string } return null; } + +export function getParamFromDialog(param: string, activatedRoute = inject(ActivatedRoute)) { + const getLastChild = (route: ActivatedRoute) => { + let child = route; + while (child.firstChild) { + child = child.firstChild; + } + return child; + }; + + return getParam(param, getLastChild(activatedRoute.root)); +} + +export function formValueToSignal(control: AbstractControl) { + return toSignal(control.valueChanges, { initialValue: control.value }); +} + +export function formStatusToSignal(control: AbstractControl) { + return toSignal(control.statusChanges, { initialValue: control.status }); +} diff --git a/src/lib/utils/headers-configuration.ts b/src/lib/utils/headers-configuration.ts index f3631f7..ef96cc0 100644 --- a/src/lib/utils/headers-configuration.ts +++ b/src/lib/utils/headers-configuration.ts @@ -1,5 +1,10 @@ import { HttpHeaders } from '@angular/common/http'; -import { HeaderOptions } from '../services/generic.service'; + +export interface HeaderOptions { + readonly authorization?: boolean; + readonly accept?: boolean; + readonly contentType?: boolean; +} export class HeadersConfiguration { static getHeaders(options?: HeaderOptions): HttpHeaders { diff --git a/src/lib/utils/iqser-app-config.ts b/src/lib/utils/iqser-app-config.ts index 29b5c2f..59cc363 100644 --- a/src/lib/utils/iqser-app-config.ts +++ b/src/lib/utils/iqser-app-config.ts @@ -8,4 +8,5 @@ export interface IqserAppConfig { readonly OAUTH_URL: string; readonly MANUAL_BASE_URL: string; readonly BASE_TRANSLATIONS_DIRECTORY?: string; + readonly LANDING_PAGE_THEME: 'redactmanager' | 'documine' | 'mixed'; } diff --git a/src/lib/utils/tokens.ts b/src/lib/utils/tokens.ts index a7cef86..5a7ecdf 100644 --- a/src/lib/utils/tokens.ts +++ b/src/lib/utils/tokens.ts @@ -1,35 +1,29 @@ import { inject, InjectionToken } from '@angular/core'; -import { PlatformLocation } from '@angular/common'; -export const BASE_HREF = new InjectionToken('BASE_HREF', { +export const UI_ROOT = new InjectionToken('UI path root - different from BASE_HREF'); +export const UI_ROOT_PATH_FN = new InjectionToken<(path: string) => string>('Append UI root to path', { factory: () => { - const baseUrl = inject(PlatformLocation).getBaseHrefFromDOM(); - if (!baseUrl) { - return ''; - } - - if (baseUrl[baseUrl.length - 1] === '/') { - return baseUrl.substring(0, baseUrl.length - 1); - } - - console.log('Base URL:', baseUrl); - - return baseUrl; - }, -}); - -export type BaseHrefFn = (path: string) => string; - -export const BASE_HREF_FN = new InjectionToken('Convert path function', { - factory: () => { - const baseUrl = inject(BASE_HREF); + const root = inject(UI_ROOT); return (path: string) => { if (path[0] === '/') { - return baseUrl + path; + return root + path; } - return baseUrl + '/' + path; + return root + '/' + path; + }; + }, +}); + +export const GET_TENANT_FROM_PATH_FN = new InjectionToken<() => string>('Parse tenant from path considering UI root', { + factory: () => { + const root = inject(UI_ROOT); + + return () => { + const pathSegments = location.pathname.split('/').filter(Boolean); + const rootPathIndex = pathSegments.indexOf(root.replace('/', '')); + const tenant = pathSegments[rootPathIndex + 1]; + return tenant ?? ''; }; }, }); diff --git a/src/lib/utils/types/common-ui-options.ts b/src/lib/utils/types/common-ui-options.ts index 130659a..d5fe61b 100644 --- a/src/lib/utils/types/common-ui-options.ts +++ b/src/lib/utils/types/common-ui-options.ts @@ -1,5 +1,6 @@ -import { IqserConfigService, IqserUserPreferenceService } from '../../services'; import { Type } from '@angular/core'; +import { IqserConfigService } from '../../services/iqser-config.service'; +import { IqserUserPreferenceService } from '../../services/iqser-user-preference.service'; import { IqserAppConfig } from '../iqser-app-config'; export interface CommonUiOptions< diff --git a/src/lib/utils/types/events.type.ts b/src/lib/utils/types/events.type.ts index 2d5c97d..93dea19 100644 --- a/src/lib/utils/types/events.type.ts +++ b/src/lib/utils/types/events.type.ts @@ -1,3 +1,4 @@ export interface IqserEventTarget extends EventTarget { localName: string; + type: string; } diff --git a/tsconfig.json b/tsconfig.json index b132006..6345cec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,6 @@ "noFallthroughCasesInSwitch": true, "noPropertyAccessFromIndexSignature": true, "sourceMap": true, - "downlevelIteration": true, "experimentalDecorators": true, "moduleResolution": "node", "importHelpers": true, @@ -21,10 +20,10 @@ "lib": ["ES2022", "dom"], "allowSyntheticDefaultImports": true, "paths": { - "@biesbjerg/ngx-translate-extract-marker": ["src/lib/translations/ngx-translate-extract-marker"] + "@biesbjerg/ngx-translate-extract-marker": ["./src/lib/translations/ngx-translate-extract-marker"] } }, - "include": ["./**/*"], + "include": ["./src/**/*"], "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, "strictInjectionParameters": true, diff --git a/tsconfig.spec.json b/tsconfig.spec.json index 423b1f2..dd570e4 100644 --- a/tsconfig.spec.json +++ b/tsconfig.spec.json @@ -5,5 +5,5 @@ "types": ["jest", "node"], "esModuleInterop": true }, - "include": ["./src/lib/**/*.spec.ts", "./src/lib/**/*.d.ts"] + "include": ["./src/lib/**/*.spec.ts", "./src/lib/**/*.d.ts", "jest.config.ts"] }