Merged PR 1939: feat(remission-list, empty-state): add comprehensive empty state handling wit...

feat(remission-list, empty-state): add comprehensive empty state handling with new appearance types

Add dedicated empty state component for remission list with smart prioritization logic:
- Department selection required state (highest priority)
- All done state when list is processed and empty
- No search results state for filtered content

Enhance ui-empty-state component with new appearance types:
- AllDone: Trophy cup icon with animated steam effects
- SelectAction: Hand pointer with dropdown interface element
- Improved visual hierarchy and spacing for all states

Update remission list to use new empty state component with proper state detection
including search term validation, department filter checking, and reload detection.

Ref: #5317, #5290
This commit is contained in:
Nino Righi
2025-09-04 14:11:19 +00:00
committed by Andreas Schickinger
parent 357485e32f
commit 3bbf79a3c3
15 changed files with 354 additions and 17 deletions

View File

@@ -0,0 +1,10 @@
@let emptyState = displayEmptyState();
@if (emptyState) {
<ui-empty-state
class="w-full justify-self-center"
[appearance]="emptyState.appearance"
[title]="emptyState.title"
[description]="emptyState.description"
>
</ui-empty-state>
}

View File

@@ -0,0 +1,91 @@
import {
ChangeDetectionStrategy,
Component,
input,
computed,
inject,
} from '@angular/core';
import { FilterService } from '@isa/shared/filter';
import { EmptyStateComponent, EmptyStateAppearance } from '@isa/ui/empty-state';
type EmptyState =
| {
title: string;
description: string;
appearance: EmptyStateAppearance;
}
| undefined;
@Component({
selector: 'remi-feature-remission-list-empty-state',
templateUrl: './remission-list-empty-state.component.html',
styleUrl: './remission-list-empty-state.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [EmptyStateComponent],
})
export class RemissionListEmptyStateComponent {
/**
* FilterService instance for managing filter state and queries.
* @private
*/
#filterService = inject(FilterService);
listFetching = input<boolean>();
isDepartment = input<boolean>();
isReloadSearch = input<boolean>();
hasValidSearchTerm = input<boolean>();
hits = input<number>();
/**
* Computed signal that determines the appropriate empty state to display
* based on the current state of the remission list, search term, and filters.
* @returns An EmptyState object with title, description, and appearance, or undefined if no empty state should be shown.
* The priority for empty states is as follows:
* 1. Department list with no department selected.
* 2. All done state when the list is fully processed and no items remain.
* 3. No results state when there are no items matching the current search and filters.
* If none of these conditions are met, returns undefined.
* @see EmptyStateAppearance for possible appearance values.
* @remarks This logic ensures that the most relevant empty state is shown to the user based on their current context.
*/
displayEmptyState = computed<EmptyState>(() => {
if (!this.listFetching()) {
// Prio 1: Abteilungsremission - Es ist noch keine Abteilung ausgewählt
if (
this.isDepartment() &&
!this.#filterService.query()?.filter['abteilungen']
) {
return {
title: 'Abteilung auswählen',
description:
'Wählen Sie zuerst eine Abteilung, anschließend werden die entsprechenden Positionen angezeigt.',
appearance: EmptyStateAppearance.SelectAction,
};
}
// Prio 2: Liste abgearbeitet und keine Artikel mehr vorhanden
if (
!this.hasValidSearchTerm() &&
this.hits() === 0 &&
this.isReloadSearch()
) {
return {
title: 'Alles erledigt',
description: 'Hier gibt es gerade nichts zu tun',
appearance: EmptyStateAppearance.AllDone,
};
}
// Prio 3: Keine Ergebnisse bei leerem Suchbegriff (nur Filter gesetzt)
if (!this.hasValidSearchTerm() && this.hits() === 0) {
return {
title: 'Keine Suchergebnisse',
description:
'Bitte prüfen Sie die Schreibweise oder ändern Sie die Filtereinstellungen.',
appearance: EmptyStateAppearance.NoResults,
};
}
}
return undefined;
});
}

View File

@@ -45,6 +45,13 @@
</div>
}
}
<remi-feature-remission-list-empty-state
[listFetching]="listFetching()"
[isDepartment]="isDepartment()"
[isReloadSearch]="searchTrigger() === 'reload'"
[hasValidSearchTerm]="hasValidSearchTerm()"
[hits]="hits()"
></remi-feature-remission-list-empty-state>
</div>
@if (remissionStarted()) {

View File

@@ -49,6 +49,7 @@ import { logger } from '@isa/core/logging';
import { RemissionProcessedHintComponent } from './remission-processed-hint/remission-processed-hint.component';
import { RemissionListDepartmentElementsComponent } from './remission-list-department-elements/remission-list-department-elements.component';
import { injectTabId } from '@isa/core/tabs';
import { RemissionListEmptyStateComponent } from './remission-list-empty-state/remission-list-empty-state.component';
function querySettingsFactory() {
return inject(ActivatedRoute).snapshot.data['querySettings'];
@@ -90,6 +91,7 @@ function querySettingsFactory() {
StatefulButtonComponent,
RemissionListDepartmentElementsComponent,
RemissionProcessedHintComponent,
RemissionListEmptyStateComponent,
],
host: {
'[class]':
@@ -167,6 +169,24 @@ export class RemissionListComponent {
*/
listItemActionInProgress = signal(false);
/**
* Computed signal for the current search term from the filter service.
* @returns The current search term string or undefined if not set.
*/
searchTerm = computed<string | undefined>(() => {
return this.#filterService.query()?.input['qs'] ?? '';
});
/**
* Computed signal indicating whether there is a valid search term.
* A valid search term is defined as a non-empty string.
* @returns True if there is a valid search term, false otherwise.
*/
hasValidSearchTerm = computed(() => {
const searchTerm = this.searchTerm();
return !!searchTerm && searchTerm.length > 0;
});
/**
* Resource signal for fetching the remission list based on current filters.
* @returns Remission list resource state.
@@ -208,6 +228,12 @@ export class RemissionListComponent {
*/
listResponseValue = computed(() => this.remissionResource.value());
/**
* Computed signal indicating whether the remission list resource is currently fetching data.
* @returns True if fetching, false otherwise.
*/
listFetching = computed(() => this.remissionResource.status() === 'loading');
/**
* Computed signal for the current in-stock response.
* @returns Array of StockInfo or undefined.
@@ -372,6 +398,7 @@ export class RemissionListComponent {
*/
emptySearchResultEffect = effect(() => {
const status = this.remissionResource.status();
const searchTerm: string | undefined = this.searchTerm();
if (status !== 'resolved') {
return;
@@ -379,7 +406,7 @@ export class RemissionListComponent {
const hasItems = !!this.remissionResource.value()?.result?.length;
if (hasItems) {
if (hasItems || !searchTerm || !this.hasValidSearchTerm()) {
return;
}
@@ -390,7 +417,7 @@ export class RemissionListComponent {
this.searchItemToRemitDialog({
data: {
searchTerm: this.#filterService.query()?.input['qs'] || '',
searchTerm,
isDepartment: this.isDepartment(),
},
}).closed.subscribe(async (result) => {
@@ -407,7 +434,6 @@ export class RemissionListComponent {
}
this.reloadListAndReturnData();
this.searchTrigger.set('reload');
}
});
});
@@ -489,6 +515,7 @@ export class RemissionListComponent {
* This method is used to refresh the displayed data after changes.
*/
reloadListAndReturnData() {
this.searchTrigger.set('reload');
this.remissionResource.reload();
this.#store.reloadReturn();
}

View File

@@ -114,7 +114,6 @@ export const createRemissionListResource = (
},
abortSignal,
);
console.log(res);
if (res) {
// Merge results if both lists are fetched
res.result = [

View File

@@ -41,7 +41,6 @@
appearance="noArticles"
>
<lib-remission-return-receipt-actions
class="mt-[1.5rem]"
[remissionReturn]="returnData()"
[displayDeleteAction]="false"
(reloadData)="returnResource.reload()"

View File

@@ -51,4 +51,12 @@
}
}
}
@if (!hasItems() && !searchResource.isLoading()) {
<ui-empty-state
class="w-full justify-self-center"
title="Keine Suchergebnisse"
description="Bitte prüfen Sie die Schreibweise."
>
</ui-empty-state>
}
</div>

View File

@@ -31,6 +31,7 @@ import { CdkTrapFocus } from '@angular/cdk/a11y';
import { TooltipDirective } from '@isa/ui/tooltip';
import { createInStockResource } from './instock.resource';
import { calculateAvailableStock } from '@isa/remission/data-access';
import { EmptyStateComponent } from '@isa/ui/empty-state';
@Component({
selector: 'remi-search-item-to-remit-list',
templateUrl: './search-item-to-remit-list.component.html',
@@ -46,6 +47,7 @@ import { calculateAvailableStock } from '@isa/remission/data-access';
CdkTrapFocus,
TooltipDirective,
NgIcon,
EmptyStateComponent,
],
providers: [provideIcons({ isaActionSearch, isaOtherInfo })],
})
@@ -66,6 +68,10 @@ export class SearchItemToRemitListComponent implements OnInit {
});
inStockResponseValue = computed(() => this.inStockResource.value());
hasItems = computed(() => {
return (this.searchResource.value()?.result?.length ?? 0) > 0;
});
stockInfoMap = computed(() => {
const infos = this.inStockResponseValue() ?? [];
return new Map(infos.map((info) => [info.itemId, info]));

View File

@@ -3,3 +3,15 @@ export const NO_RESULTS =
export const NO_ARTICLES =
'<svg xmlns="http://www.w3.org/2000/svg" width="113" height="102" viewBox="0 0 113 102" fill="none"> <path d="M74.5 84L75 30.1846L61.4243 15H27.2699C22.7025 14.9998 19 18.5625 19 22.9574V82.0424C19 86.4373 22.7025 90 27.2699 90H66.7301C70.8894 90 73.9181 87.8467 74.5 84Z" stroke="#CED4DA" stroke-width="3" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/> <path d="M61 16V30H74" stroke="#CED4DA" stroke-width="3" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/> <path d="M54.25 61.5L39.75 47" stroke="#6C757D" stroke-width="3" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/> <path d="M39.75 61.5L54.25 47" stroke="#6C757D" stroke-width="3" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/></svg>';
export const ALL_DONE_CUP =
'<svg class="ui-empty-state-icon-all-done-cup" xmlns="http://www.w3.org/2000/svg" width="76" height="49" viewBox="0 0 76 49" fill="none"><g clip-path="url(#clip0_2623_8564)"><path d="M74.5156 4.53658H62.3438V1.45543C62.3438 0.652033 61.6787 0 60.8594 0H28.7969C28.7969 0 28.7954 0 28.7939 0H1.48438C0.665 0 0 0.652033 0 1.45543V14.0697C0 19.5712 1.50812 24.965 4.36109 29.669C6.28484 32.8418 8.78156 35.6421 11.7058 37.9387H1.48438C0.665 37.9387 0 38.5908 0 39.3942C0 44.6905 4.39523 49 9.79688 49H56.1094C61.511 49 65.9062 44.6905 65.9062 39.3942C65.9062 38.5908 65.2412 37.9387 64.4219 37.9387H50.638C53.3291 35.8254 55.6566 33.2843 57.5106 30.4185H61.701C69.586 30.4185 76 24.1281 76 16.3983V5.99201C76 5.18861 75.335 4.53658 74.5156 4.53658ZM62.3438 14.0697V13.2692H67.0938V16.3983C67.0938 19.3136 64.6742 21.6859 61.701 21.6859H61.3641C62.0112 19.2102 62.3438 16.6516 62.3438 14.0697ZM62.7757 40.8496C62.0959 43.8434 59.3661 46.0891 56.1094 46.0891H9.79688C6.54164 46.0891 3.81039 43.8434 3.13203 40.8496H62.7757ZM45.4189 37.9387H16.9248C8.30656 32.9815 2.96875 23.8705 2.96875 14.0697V2.91086H29.1605C29.1605 2.91086 29.162 2.91086 29.1635 2.91086H59.375V14.0697C59.375 23.872 54.0372 32.9815 45.4189 37.9387ZM73.0312 16.3983C73.0312 22.5243 67.9488 27.5076 61.701 27.5076H59.1746C59.6481 26.5587 60.0712 25.5865 60.4423 24.5968H61.701C66.3115 24.5968 70.0625 20.9189 70.0625 16.3983V11.8137C70.0625 11.0103 69.3975 10.3583 68.5781 10.3583H62.3438V7.44744H73.0312V16.3983Z" fill="#CED4DA"/><path d="M53.7341 12.6142C52.9148 12.6142 52.2498 13.2663 52.2498 14.0697C52.2498 19.4722 50.0143 24.7816 46.1178 28.6356C45.5404 29.2061 45.5448 30.1274 46.1267 30.6936C46.4162 30.9745 46.7932 31.1157 47.1717 31.1157C47.5532 31.1157 47.9362 30.9716 48.2256 30.6849C52.6683 26.2895 55.217 20.2334 55.217 14.0697C55.217 13.2663 54.552 12.6142 53.7326 12.6142H53.7341Z" fill="#CED4DA"/></g><defs><clipPath id="clip0_2623_8564"><rect width="76" height="49" fill="white"/></clipPath></defs></svg>';
export const ALL_DONE_FUME =
'<svg class="ui-empty-state-icon-all-done-fume" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none"><g clip-path="url(#clip0_2623_8567)"><path d="M15.7379 9.94429L14.7906 8.77089C13.3988 7.04762 13.397 4.63971 14.7851 2.91464C15.41 2.13657 15.2788 1.00629 14.49 0.389937C13.7012 -0.226415 12.5553 -0.0970354 11.9305 0.681042C9.48208 3.72507 9.48572 7.97305 11.9396 11.0117L12.8869 12.1851C14.705 14.4367 14.7086 17.5831 12.8942 19.8383L11.9232 21.0458C11.2983 21.8239 11.4295 22.9542 12.2183 23.5705C12.5535 23.8329 12.9525 23.9605 13.3496 23.9605C13.887 23.9605 14.419 23.7269 14.7778 23.2812L15.7488 22.0737C18.6235 18.5013 18.6199 13.5148 15.7379 9.94609V9.94429Z" fill="#6C757D"/><path d="M25.8356 13.9856L24.8883 12.8122C23.4965 11.0889 23.4946 8.68103 24.8828 6.95597C25.5076 6.17789 25.3765 5.04761 24.5877 4.43126C23.7989 3.81491 22.653 3.94429 22.0281 4.72236C19.5797 7.76639 19.5834 12.0126 22.0372 15.053L22.9845 16.2264C24.8026 18.478 24.8045 21.6262 22.9918 23.8814L22.0209 25.0889C21.396 25.867 21.5272 26.9973 22.316 27.6136C22.6512 27.876 23.0501 28.0036 23.4473 28.0036C23.9847 28.0036 24.5166 27.77 24.8755 27.3243L25.8465 26.1168C28.7212 22.5445 28.7175 17.5579 25.8356 13.9892V13.9856Z" fill="#6C757D"/><path d="M5.64024 13.9856L4.69294 12.8122C3.30114 11.0889 3.29932 8.68103 4.68748 6.95597C5.31233 6.17789 5.18117 5.04761 4.39236 4.43126C3.60355 3.81491 2.45768 3.94429 1.83283 4.72236C-0.61558 7.76639 -0.611937 12.0144 1.84193 15.053L2.78923 16.2264C4.60732 18.478 4.60914 21.6262 2.79652 23.8796L1.82554 25.0871C1.20069 25.8652 1.33185 26.9955 2.12066 27.6119C2.45586 27.8742 2.85482 28.0018 3.25195 28.0018C3.78937 28.0018 4.32131 27.7682 4.68019 27.3225L5.65117 26.115C8.52587 22.5427 8.52222 17.5561 5.64024 13.9874V13.9856Z" fill="#6C757D"/></g><defs><clipPath id="clip0_2623_8567"><rect width="28" height="28" fill="white"/></clipPath></defs></svg>';
export const SELECT_ACTION_HAND =
'<svg class="ui-empty-state-icon-select-action-hand" xmlns="http://www.w3.org/2000/svg" width="44" height="62" viewBox="0 0 44 62" fill="none"><path d="M12.1722 44.1737C11.4694 44.1737 10.8989 43.6033 10.8989 42.9004V13.6147C10.8989 10.8058 13.1832 8.52148 15.9921 8.52148C18.801 8.52148 21.0853 10.8058 21.0853 13.6147V40.3538C21.0853 41.0567 20.5148 41.6271 19.812 41.6271C19.1091 41.6271 18.5387 41.0567 18.5387 40.3538V13.6147C18.5387 12.2089 17.3978 11.0681 15.9921 11.0681C14.5864 11.0681 13.4455 12.2089 13.4455 13.6147V42.9004C13.4455 43.6033 12.8751 44.1737 12.1722 44.1737Z" fill="#6C757D"/><path d="M27.4521 41.6269C26.7493 41.6269 26.1788 41.0565 26.1788 40.3536V28.894C26.1788 27.4882 25.038 26.3474 23.6322 26.3474C22.2265 26.3474 21.0856 27.4882 21.0856 28.894C21.0856 29.5968 20.5152 30.1672 19.8124 30.1672C19.1095 30.1672 18.5391 29.5968 18.5391 28.894C18.5391 26.0851 20.8234 23.8008 23.6322 23.8008C26.4411 23.8008 28.7254 26.0851 28.7254 28.894V40.3536C28.7254 41.0565 28.155 41.6269 27.4521 41.6269Z" fill="#6C757D"/><path d="M35.0918 41.6272C34.3889 41.6272 33.8185 41.0567 33.8185 40.3539V31.4408C33.8185 30.0351 32.6776 28.8942 31.2719 28.8942C29.8662 28.8942 28.7253 30.0351 28.7253 31.4408C28.7253 32.1437 28.1549 32.7141 27.452 32.7141C26.7491 32.7141 26.1787 32.1437 26.1787 31.4408C26.1787 28.6319 28.463 26.3477 31.2719 26.3477C34.0808 26.3477 36.3651 28.6319 36.3651 31.4408V40.3539C36.3651 41.0567 35.7946 41.6272 35.0918 41.6272Z" fill="#6C757D"/><path d="M32.5451 62.0002H17.8081C13.5221 62.0002 9.10127 60.1335 5.9766 57.0063C2.52853 53.5557 0.707716 48.6331 0.715356 42.776C0.717903 37.9324 4.76952 33.9877 9.74301 33.9877H12.1725C12.8753 33.9877 13.4457 34.5581 13.4457 35.261C13.4457 35.9639 12.8753 36.5343 12.1725 36.5343H9.74301C6.17269 36.5343 3.26449 39.3381 3.26194 42.7785C3.25685 48.0194 4.77716 52.1984 7.77704 55.2059C10.9068 58.3382 15.0094 59.4536 17.8081 59.4536H32.5451C37.4601 59.4536 41.4582 55.4554 41.4582 50.5405V33.9877C41.4582 32.582 40.3173 31.4411 38.9116 31.4411C37.5059 31.4411 36.365 32.582 36.365 33.9877C36.365 34.6906 35.7946 35.261 35.0917 35.261C34.3889 35.261 33.8184 34.6906 33.8184 33.9877C33.8184 31.1788 36.1027 28.8945 38.9116 28.8945C41.7205 28.8945 44.0048 31.1788 44.0048 33.9877V50.5405C44.0048 56.8586 38.8632 62.0002 32.5451 62.0002Z" fill="#6C757D"/><path d="M24.9053 22.0694C24.6226 22.0694 24.3348 21.9752 24.1005 21.7817C23.5556 21.336 23.4766 20.5364 23.9223 19.9914C25.3968 18.1833 26.1786 15.978 26.1786 13.6148C26.1786 7.997 21.61 3.42842 15.9922 3.42842C10.3744 3.42842 5.80586 7.997 5.80586 13.6148C5.80586 15.978 6.58767 18.1833 8.06469 19.9914C8.51034 20.5364 8.4314 21.336 7.88643 21.7817C7.344 22.2299 6.54183 22.1484 6.09618 21.6034C4.23971 19.3344 3.25928 16.5714 3.25928 13.6148C3.25928 6.59383 8.97127 0.881836 15.9922 0.881836C23.0132 0.881836 28.7251 6.59383 28.7251 13.6148C28.7251 16.5714 27.7447 19.3344 25.8908 21.6034C25.6387 21.9116 25.2745 22.0694 24.9053 22.0694Z" fill="#6C757D"/></svg>';
export const SELECT_ACTION_OBJECT_DROPDOWN =
'<svg class="ui-empty-state-icon-select-action-object-dropdown" xmlns="http://www.w3.org/2000/svg" width="149" height="44" viewBox="0 0 149 44" fill="none"><path d="M90.8125 42.1992H22.2244C11.1787 42.1992 2.22437 33.2449 2.22437 22.1992V22.1992C2.22437 11.1535 11.1787 2.19922 22.2244 2.19922H127.5C138.546 2.19922 147.5 11.1535 147.5 22.1992V22.1992C147.5 33.2449 138.546 42.1992 127.5 42.1992H98.875" stroke="#CED4DA" stroke-width="3"/><line x1="80.75" y1="22.4365" x2="26.25" y2="22.4365" stroke="#CED4DA" stroke-width="3" stroke-linecap="round"/><path d="M118.641 20.0596L125.005 26.4874L131.369 20.0596" stroke="#CED4DA" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>';

View File

@@ -1,5 +1,7 @@
@if (sanitizedSubIcon()) {
<div class="h-0" [innerHTML]="sanitizedSubIcon()"></div>
}
<div class="ui-empty-state-circle" [innerHTML]="sanitizedIcon()"></div>
<div></div>
<div class="ui-empty-state-title">{{ title() }}</div>
<div class="ui-empty-state-description">{{ description() }}</div>
<div class="ui-empty-state-actions">

View File

@@ -6,7 +6,6 @@
justify-content: center;
justify-items: center;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
@@ -20,20 +19,30 @@
@apply rounded-full bg-isa-neutral-100;
}
.ui-empty-state-icon-all-done-cup {
@apply h-[3.0625rem] w-full ml-3 mt-8;
}
.ui-empty-state-icon-all-done-fume {
@apply h-[1.75rem] w-full relative top-4;
}
.ui-empty-state-icon-select-action-object-dropdown {
@apply w-full scale-[1.15] h-10;
}
.ui-empty-state-icon-select-action-hand {
@apply w-full h-[3.81988rem] relative top-[3.70512rem] left-[1.7rem] z-[1];
}
.ui-empty-state-icon {
width: 7.0625rem;
height: 6.375rem;
flex-shrink: 0;
}
.ui-empty-state-spacer {
width: 0.75rem;
height: 0.75rem;
flex-shrink: 0;
}
.ui-empty-state-title {
@apply text-isa-black isa-text-subtitle-1-regular text-center;
@apply text-isa-black isa-text-subtitle-1-regular text-center mb-3 mt-11;
}
.ui-empty-state-description {
@@ -41,5 +50,5 @@
}
.ui-empty-state-actions {
@apply flex flex-row gap-2;
@apply flex flex-row gap-2 mt-11;
}

View File

@@ -1,9 +1,36 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { DomSanitizer } from '@angular/platform-browser';
import { EmptyStateComponent } from './empty-state.component';
import { EmptyStateAppearance } from './types';
import {
NO_RESULTS,
NO_ARTICLES,
ALL_DONE_CUP,
ALL_DONE_FUME,
SELECT_ACTION_HAND,
SELECT_ACTION_OBJECT_DROPDOWN,
} from './constants';
describe('EmptyStateComponent', () => {
let spectator: Spectator<EmptyStateComponent>;
const createComponent = createComponentFactory(EmptyStateComponent);
let mockSanitizer: jest.Mocked<DomSanitizer>;
const createComponent = createComponentFactory({
component: EmptyStateComponent,
providers: [
{
provide: DomSanitizer,
useValue: {
bypassSecurityTrustHtml: jest.fn(),
bypassSecurityTrustStyle: jest.fn(),
bypassSecurityTrustScript: jest.fn(),
bypassSecurityTrustUrl: jest.fn(),
bypassSecurityTrustResourceUrl: jest.fn(),
sanitize: jest.fn(),
},
},
],
});
beforeEach(() => {
spectator = createComponent({
@@ -12,6 +39,10 @@ describe('EmptyStateComponent', () => {
description: 'Test Description',
},
});
mockSanitizer = spectator.inject(DomSanitizer) as jest.Mocked<DomSanitizer>;
// Clear any calls made during component initialization
mockSanitizer.bypassSecurityTrustHtml.mockClear();
});
it('should create the component', () => {
@@ -26,4 +57,109 @@ describe('EmptyStateComponent', () => {
it('should apply the host class "ui-empty-state"', () => {
expect(spectator.element.classList.contains('ui-empty-state')).toBe(true);
});
it('should have default appearance as NoResults', () => {
expect(spectator.component.appearance()).toBe(
EmptyStateAppearance.NoResults,
);
});
it('should set appearance input correctly', () => {
spectator.setInput('appearance', EmptyStateAppearance.AllDone);
expect(spectator.component.appearance()).toBe(EmptyStateAppearance.AllDone);
});
describe('icon computed property', () => {
it('should return NO_RESULTS icon for NoResults appearance', () => {
spectator.setInput('appearance', EmptyStateAppearance.NoResults);
expect(spectator.component.icon()).toBe(NO_RESULTS);
});
it('should return NO_ARTICLES icon for NoArticles appearance', () => {
spectator.setInput('appearance', EmptyStateAppearance.NoArticles);
expect(spectator.component.icon()).toBe(NO_ARTICLES);
});
it('should return ALL_DONE_CUP icon for AllDone appearance', () => {
spectator.setInput('appearance', EmptyStateAppearance.AllDone);
expect(spectator.component.icon()).toBe(ALL_DONE_CUP);
});
it('should return SELECT_ACTION_OBJECT_DROPDOWN icon for SelectAction appearance', () => {
spectator.setInput('appearance', EmptyStateAppearance.SelectAction);
expect(spectator.component.icon()).toBe(SELECT_ACTION_OBJECT_DROPDOWN);
});
it('should return NO_RESULTS icon for default case', () => {
expect(spectator.component.icon()).toBe(NO_RESULTS);
});
});
describe('subIcon computed property', () => {
it('should return ALL_DONE_FUME for AllDone appearance', () => {
spectator.setInput('appearance', EmptyStateAppearance.AllDone);
expect(spectator.component.subIcon()).toBe(ALL_DONE_FUME);
});
it('should return SELECT_ACTION_HAND for SelectAction appearance', () => {
spectator.setInput('appearance', EmptyStateAppearance.SelectAction);
expect(spectator.component.subIcon()).toBe(SELECT_ACTION_HAND);
});
it('should return empty string for NoResults appearance', () => {
spectator.setInput('appearance', EmptyStateAppearance.NoResults);
expect(spectator.component.subIcon()).toBe('');
});
it('should return empty string for NoArticles appearance', () => {
spectator.setInput('appearance', EmptyStateAppearance.NoArticles);
expect(spectator.component.subIcon()).toBe('');
});
});
describe('sanitizedSubIcon computed property', () => {
it('should return sanitized subIcon when subIcon has content', () => {
// Arrange
const mockSafeHtml = { toString: () => 'sanitized-sub-icon' };
mockSanitizer.bypassSecurityTrustHtml.mockReturnValue(
mockSafeHtml as any,
);
spectator.setInput('appearance', EmptyStateAppearance.AllDone);
// Act
const result = spectator.component.sanitizedSubIcon();
// Assert
expect(mockSanitizer.bypassSecurityTrustHtml).toHaveBeenCalledWith(
ALL_DONE_FUME,
);
expect(result).toBe(mockSafeHtml);
});
it('should return undefined when subIcon is empty', () => {
// Arrange
spectator.setInput('appearance', EmptyStateAppearance.NoResults);
// Act
const result = spectator.component.sanitizedSubIcon();
// Assert
expect(result).toBeUndefined();
});
});
describe('component integration', () => {
it('should update computed properties when appearance changes', () => {
// Arrange - Start with NoResults
expect(spectator.component.icon()).toBe(NO_RESULTS);
expect(spectator.component.subIcon()).toBe('');
// Act - Change to AllDone
spectator.setInput('appearance', EmptyStateAppearance.AllDone);
// Assert - Properties should update
expect(spectator.component.icon()).toBe(ALL_DONE_CUP);
expect(spectator.component.subIcon()).toBe(ALL_DONE_FUME);
});
});
});

View File

@@ -7,7 +7,14 @@ import {
ViewEncapsulation,
} from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { NO_RESULTS, NO_ARTICLES } from './constants';
import {
NO_RESULTS,
NO_ARTICLES,
ALL_DONE_CUP,
ALL_DONE_FUME,
SELECT_ACTION_HAND,
SELECT_ACTION_OBJECT_DROPDOWN,
} from './constants';
import { EmptyStateAppearance } from './types';
@Component({
@@ -33,13 +40,35 @@ export class EmptyStateComponent {
switch (appearance) {
case EmptyStateAppearance.NoArticles:
return NO_ARTICLES;
case EmptyStateAppearance.AllDone:
return ALL_DONE_CUP;
case EmptyStateAppearance.SelectAction:
return SELECT_ACTION_OBJECT_DROPDOWN;
case EmptyStateAppearance.NoResults:
default:
return NO_RESULTS;
}
});
subIcon = computed(() => {
const appearance = this.appearance();
switch (appearance) {
case EmptyStateAppearance.AllDone:
return ALL_DONE_FUME;
case EmptyStateAppearance.SelectAction:
return SELECT_ACTION_HAND;
default:
return '';
}
});
sanitizedIcon = computed(() => {
return this.#sanitizer.bypassSecurityTrustHtml(this.icon());
});
sanitizedSubIcon = computed(() => {
return this.subIcon().length > 0
? this.#sanitizer.bypassSecurityTrustHtml(this.subIcon())
: undefined;
});
}

View File

@@ -1,6 +1,8 @@
export const EmptyStateAppearance = {
NoResults: 'noResults',
NoArticles: 'noArticles',
AllDone: 'allDone',
SelectAction: 'selectAction',
} as const;
export type EmptyStateAppearance =