Vorschläge
This commit is contained in:
@@ -88,7 +88,9 @@ describe('AssistantChatComponent', () => {
|
||||
});
|
||||
|
||||
it('falls back to route context when list context loading fails', async () => {
|
||||
listsService.getList.mockReturnValue(throwError(() => new Error('not found')));
|
||||
listsService.getList.mockReturnValue(
|
||||
throwError(() => new Error('not found')),
|
||||
);
|
||||
await router.navigateByUrl('/lists/list-1');
|
||||
const fixture = TestBed.createComponent(AssistantChatComponent);
|
||||
const component = fixture.componentInstance as unknown as {
|
||||
|
||||
@@ -214,6 +214,75 @@
|
||||
Hinzufügen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if (canEditItems()) {
|
||||
<section class="suggestions-panel" aria-label="Smart Suggestions">
|
||||
<div class="suggestions-header">
|
||||
<div>
|
||||
<h3>Smart Suggestions</h3>
|
||||
<p>Passende Items basierend auf Name, Beschreibung und Inhalt.</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="loadingSuggestions()"
|
||||
(click)="loadSuggestions()"
|
||||
>
|
||||
@if (loadingSuggestions()) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||
} @else {
|
||||
<mat-icon aria-hidden="true">auto_awesome</mat-icon>
|
||||
}
|
||||
Vorschlaege
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (itemSuggestions().length > 0) {
|
||||
<ul class="suggestion-list">
|
||||
@for (suggestion of itemSuggestions(); track suggestion.title) {
|
||||
<li>
|
||||
<div>
|
||||
<strong>{{ suggestion.title }}</strong>
|
||||
@if (suggestion.notes || suggestion.quantity || !suggestion.required) {
|
||||
<span>
|
||||
@if (suggestion.quantity) {
|
||||
Menge: {{ suggestion.quantity }}
|
||||
}
|
||||
@if (suggestion.notes) {
|
||||
{{ suggestion.quantity ? '- ' : '' }}{{ suggestion.notes }}
|
||||
}
|
||||
@if (!suggestion.required) {
|
||||
{{ suggestion.quantity || suggestion.notes ? '- ' : '' }}Optional
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[attr.aria-label]="suggestion.title + ' hinzufuegen'"
|
||||
[disabled]="addingSuggestionTitle() === suggestion.title"
|
||||
(click)="addSuggestion(suggestion)"
|
||||
>
|
||||
@if (addingSuggestionTitle() === suggestion.title) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||
} @else {
|
||||
<mat-icon aria-hidden="true">add</mat-icon>
|
||||
}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (suggestionsLoaded()) {
|
||||
<div class="inline-empty">
|
||||
<mat-icon aria-hidden="true">tips_and_updates</mat-icon>
|
||||
<span>Keine neuen Vorschlaege gefunden.</span>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!canEditItems()) {
|
||||
@@ -223,7 +292,7 @@
|
||||
</div>
|
||||
} @else if (list()?.items?.length) {
|
||||
<ul class="check-items">
|
||||
@for (item of list()!.items; track item.id) {
|
||||
@for (item of visibleItems(list()!); track item.id) {
|
||||
<li [class.checked]="item.checked">
|
||||
<mat-checkbox
|
||||
[checked]="item.checked"
|
||||
|
||||
@@ -56,7 +56,8 @@
|
||||
}
|
||||
|
||||
.share-results,
|
||||
.collaborator-list {
|
||||
.collaborator-list,
|
||||
.suggestion-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0 0;
|
||||
@@ -65,7 +66,8 @@
|
||||
}
|
||||
|
||||
.share-results li,
|
||||
.collaborator-list li {
|
||||
.collaborator-list li,
|
||||
.suggestion-list li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
@@ -79,24 +81,29 @@
|
||||
|
||||
.share-results span,
|
||||
.collaborator-list strong,
|
||||
.collaborator-list span {
|
||||
.collaborator-list span,
|
||||
.suggestion-list strong,
|
||||
.suggestion-list span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collaborator-list div {
|
||||
.collaborator-list div,
|
||||
.suggestion-list div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.collaborator-list span {
|
||||
.collaborator-list span,
|
||||
.suggestion-list span {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.share-results mat-progress-spinner,
|
||||
.collaborator-list mat-progress-spinner {
|
||||
.collaborator-list mat-progress-spinner,
|
||||
.suggestion-list mat-progress-spinner {
|
||||
display: inline-flex;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
@@ -130,6 +137,23 @@
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.suggestions-panel {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
.suggestions-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.suggestions-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.state-card mat-card-content {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
|
||||
@@ -16,7 +16,12 @@ import { AuthService } from '../../auth/auth.service';
|
||||
import { getAuthErrorMessage } from '../../auth/error-message';
|
||||
import { PublicUserSearchResult } from '../../auth/auth.models';
|
||||
import { OnboardingService } from '../../onboarding/onboarding.service';
|
||||
import { ListRealtimeEvent, UserList, UserListItem } from '../lists.models';
|
||||
import {
|
||||
ListItemSuggestion,
|
||||
ListRealtimeEvent,
|
||||
UserList,
|
||||
UserListItem,
|
||||
} from '../lists.models';
|
||||
import { ListsRealtimeService } from '../lists-realtime.service';
|
||||
import { ListsService } from '../lists.service';
|
||||
|
||||
@@ -55,8 +60,12 @@ export class ListDetailComponent implements OnInit {
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly editing = signal(false);
|
||||
protected readonly addingItem = signal(false);
|
||||
protected readonly loadingSuggestions = signal(false);
|
||||
protected readonly suggestionsLoaded = signal(false);
|
||||
protected readonly errorMessage = signal<string | null>(null);
|
||||
protected readonly updatingItemId = signal<string | null>(null);
|
||||
protected readonly addingSuggestionTitle = signal<string | null>(null);
|
||||
protected readonly itemSuggestions = signal<ListItemSuggestion[]>([]);
|
||||
protected readonly shareSearchTerm = signal('');
|
||||
protected readonly shareSearchResults = signal<PublicUserSearchResult[]>([]);
|
||||
protected readonly searchingUsers = signal(false);
|
||||
@@ -201,6 +210,63 @@ export class ListDetailComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
protected loadSuggestions(): void {
|
||||
const listId = this.listId();
|
||||
|
||||
if (!listId || !this.canEditItems() || this.loadingSuggestions()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadingSuggestions.set(true);
|
||||
this.suggestionsLoaded.set(false);
|
||||
|
||||
this.listsService
|
||||
.suggestItems(listId)
|
||||
.pipe(finalize(() => this.loadingSuggestions.set(false)))
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.itemSuggestions.set(response.suggestions);
|
||||
this.suggestionsLoaded.set(true);
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected addSuggestion(suggestion: ListItemSuggestion): void {
|
||||
const listId = this.listId();
|
||||
|
||||
if (!listId || this.addingSuggestionTitle()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addingSuggestionTitle.set(suggestion.title);
|
||||
this.listsService
|
||||
.addItem(listId, {
|
||||
title: suggestion.title,
|
||||
notes: suggestion.notes,
|
||||
quantity: suggestion.quantity,
|
||||
required: suggestion.required,
|
||||
})
|
||||
.pipe(finalize(() => this.addingSuggestionTitle.set(null)))
|
||||
.subscribe({
|
||||
next: (list) => {
|
||||
this.setList(list);
|
||||
this.itemSuggestions.update((suggestions) =>
|
||||
suggestions.filter(
|
||||
(itemSuggestion) =>
|
||||
this.suggestionKey(itemSuggestion.title) !==
|
||||
this.suggestionKey(suggestion.title),
|
||||
),
|
||||
);
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected toggleItem(item: UserListItem, checked: boolean): void {
|
||||
const listId = this.listId();
|
||||
const currentList = this.list();
|
||||
@@ -258,6 +324,10 @@ export class ListDetailComponent implements OnInit {
|
||||
return list.items.filter((item) => item.checked).length;
|
||||
}
|
||||
|
||||
protected visibleItems(list: UserList): UserListItem[] {
|
||||
return this.uncheckedFirst(list.items);
|
||||
}
|
||||
|
||||
protected searchShareUsers(term: string): void {
|
||||
this.shareSearchTerm.set(term);
|
||||
|
||||
@@ -374,4 +444,21 @@ export class ListDetailComponent implements OnInit {
|
||||
private listId(): string | null {
|
||||
return this.route.snapshot.paramMap.get('listId');
|
||||
}
|
||||
|
||||
private uncheckedFirst(items: UserListItem[]): UserListItem[] {
|
||||
return items
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort((a, b) => {
|
||||
if (a.item.checked !== b.item.checked) {
|
||||
return a.item.checked ? 1 : -1;
|
||||
}
|
||||
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
|
||||
private suggestionKey(value: string): string {
|
||||
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
|
||||
@if (list.items.length > 0) {
|
||||
<ul class="template-items">
|
||||
@for (item of list.items.slice(0, 4); track item.id) {
|
||||
@for (item of previewItems(list); track item.id) {
|
||||
<li>
|
||||
<mat-icon aria-hidden="true">
|
||||
{{ item.checked ? 'check_circle' : 'radio_button_unchecked' }}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { MatSelectModule } from '@angular/material/select';
|
||||
import { getAuthErrorMessage } from '../auth/error-message';
|
||||
import { OnboardingService } from '../onboarding/onboarding.service';
|
||||
import { ListTemplateKind } from '../templates/templates.models';
|
||||
import { ListRealtimeEvent, UserList } from './lists.models';
|
||||
import { ListRealtimeEvent, UserList, UserListItem } from './lists.models';
|
||||
import { ListsRealtimeService } from './lists-realtime.service';
|
||||
import { ListsService } from './lists.service';
|
||||
|
||||
@@ -152,6 +152,10 @@ export class ListsComponent implements OnInit {
|
||||
return list.items.filter((item) => item.checked).length;
|
||||
}
|
||||
|
||||
protected previewItems(list: UserList): UserListItem[] {
|
||||
return this.uncheckedFirst(list.items).slice(0, 4);
|
||||
}
|
||||
|
||||
protected progressLabel(list: UserList): string {
|
||||
if (list.items.length === 0) {
|
||||
return '0%';
|
||||
@@ -230,4 +234,17 @@ export class ListsComponent implements OnInit {
|
||||
private dateValue(value: string): number {
|
||||
return new Date(value).getTime();
|
||||
}
|
||||
|
||||
private uncheckedFirst(items: UserListItem[]): UserListItem[] {
|
||||
return items
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort((a, b) => {
|
||||
if (a.item.checked !== b.item.checked) {
|
||||
return a.item.checked ? 1 : -1;
|
||||
}
|
||||
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,17 @@ export interface AddListItemRequest {
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface ListItemSuggestion {
|
||||
title: string;
|
||||
notes?: string;
|
||||
quantity?: number;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface ListItemSuggestionsResponse {
|
||||
suggestions: ListItemSuggestion[];
|
||||
}
|
||||
|
||||
export interface UpdateListItemRequest {
|
||||
title?: string;
|
||||
notes?: string;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Observable } from 'rxjs';
|
||||
import {
|
||||
AddListItemRequest,
|
||||
CreateListRequest,
|
||||
ListItemSuggestionsResponse,
|
||||
UpdateListItemRequest,
|
||||
UpdateListRequest,
|
||||
UserList,
|
||||
@@ -42,6 +43,13 @@ export class ListsService {
|
||||
return this.http.post<UserList>(`${this.apiUrl}/${listId}/items`, data);
|
||||
}
|
||||
|
||||
suggestItems(listId: string): Observable<ListItemSuggestionsResponse> {
|
||||
return this.http.post<ListItemSuggestionsResponse>(
|
||||
`${this.apiUrl}/${listId}/item-suggestions`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
updateItem(
|
||||
listId: string,
|
||||
itemId: string,
|
||||
|
||||
Reference in New Issue
Block a user