template sharing
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
<section class="template-detail-page">
|
||||
<header class="detail-header">
|
||||
<button mat-icon-button type="button" aria-label="Zurück" (click)="backToTemplates()">
|
||||
<button mat-icon-button type="button" aria-label="Zurueck" (click)="backToTemplates()">
|
||||
<mat-icon aria-hidden="true">arrow_back</mat-icon>
|
||||
</button>
|
||||
<div>
|
||||
<h1>{{ template()?.name || (isCreateMode() ? 'Neues Template' : 'Template') }}</h1>
|
||||
<p>{{ isCreateMode() ? 'Vorlage anlegen' : 'Vorlage bearbeiten' }}</p>
|
||||
<p>
|
||||
{{ isCreateMode() ? 'Vorlage anlegen' : 'Vorlage bearbeiten' }}
|
||||
@if (template()?.accessRole === 'collaborator') {
|
||||
- geteilt von {{ template()!.ownerName || template()!.ownerEmail || 'Owner' }}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
@if (canEditItems()) {
|
||||
<div class="detail-actions">
|
||||
@@ -23,13 +28,15 @@
|
||||
}
|
||||
Als Liste
|
||||
</button>
|
||||
<button mat-icon-button type="button" aria-label="Template löschen" [disabled]="deletingTemplate()" (click)="deleteTemplate()">
|
||||
@if (deletingTemplate()) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||
} @else {
|
||||
<mat-icon aria-hidden="true">delete</mat-icon>
|
||||
}
|
||||
</button>
|
||||
@if (canDeleteTemplate()) {
|
||||
<button mat-icon-button type="button" aria-label="Template loeschen" [disabled]="deletingTemplate()" (click)="deleteTemplate()">
|
||||
@if (deletingTemplate()) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||
} @else {
|
||||
<mat-icon aria-hidden="true">delete</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
@@ -86,17 +93,115 @@
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
@if (template() && (canManageShares() || template()!.collaborators.length > 0 || template()!.accessRole === 'collaborator')) {
|
||||
<mat-card class="editor-card" appearance="outlined">
|
||||
<mat-card-header>
|
||||
<mat-card-title>Freigaben</mat-card-title>
|
||||
<mat-card-subtitle>
|
||||
@if (canManageShares()) {
|
||||
{{ template()!.collaborators.length }} Mitwirkende
|
||||
} @else {
|
||||
Geteilt von {{ template()!.ownerName || template()!.ownerEmail || 'Owner' }}
|
||||
}
|
||||
</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
@if (canManageShares()) {
|
||||
<mat-form-field appearance="outline" class="share-search-field">
|
||||
<mat-label>User suchen</mat-label>
|
||||
<mat-icon matPrefix aria-hidden="true">person_search</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
type="search"
|
||||
[value]="shareSearchTerm()"
|
||||
(input)="searchShareUsers($any($event.target).value)"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchingUsers()) {
|
||||
<mat-progress-spinner matSuffix mode="indeterminate" diameter="18" />
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (availableShareSearchResults().length > 0) {
|
||||
<ul class="share-results">
|
||||
@for (user of availableShareSearchResults(); track user.id) {
|
||||
<li>
|
||||
<span>{{ displayUser(user) }}</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="sharingUserId() === user.id"
|
||||
(click)="shareWithUser(user)"
|
||||
>
|
||||
@if (sharingUserId() === user.id) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||
} @else {
|
||||
<mat-icon aria-hidden="true">person_add</mat-icon>
|
||||
}
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (shareSearchTerm().trim().length >= 2 && !searchingUsers()) {
|
||||
<div class="inline-empty">
|
||||
<mat-icon aria-hidden="true">person_off</mat-icon>
|
||||
<span>Keine passenden User gefunden.</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (template()!.collaborators.length > 0) {
|
||||
<ul class="collaborator-list">
|
||||
@for (collaborator of template()!.collaborators; track collaborator.id) {
|
||||
<li>
|
||||
<div>
|
||||
<strong>{{ collaborator.name || collaborator.email }}</strong>
|
||||
@if (collaborator.name) {
|
||||
<span>{{ collaborator.email }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (canManageShares()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[attr.aria-label]="displayUser(collaborator) + ' entfernen'"
|
||||
[disabled]="removingShareUserId() === collaborator.id"
|
||||
(click)="removeCollaborator(collaborator.id)"
|
||||
>
|
||||
@if (removingShareUserId() === collaborator.id) {
|
||||
<mat-progress-spinner mode="indeterminate" diameter="18" />
|
||||
} @else {
|
||||
<mat-icon aria-hidden="true">person_remove</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<div class="inline-empty">
|
||||
<mat-icon aria-hidden="true">group</mat-icon>
|
||||
<span>Noch keine Mitwirkenden.</span>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<mat-card class="editor-card" appearance="outlined" data-onboarding="template-item">
|
||||
<mat-card-header>
|
||||
<mat-card-title>Items</mat-card-title>
|
||||
<mat-card-subtitle>
|
||||
@if (canEditItems()) {
|
||||
{{ template()?.items?.length || 0 }} Einträge
|
||||
{{ template()?.items?.length || 0 }} Eintraege
|
||||
@if (reordering()) {
|
||||
- Reihenfolge wird gespeichert
|
||||
}
|
||||
} @else {
|
||||
Nach dem Speichern verfügbar
|
||||
Nach dem Speichern verfuegbar
|
||||
}
|
||||
</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
@@ -119,14 +224,14 @@
|
||||
} @else {
|
||||
<mat-icon aria-hidden="true">add</mat-icon>
|
||||
}
|
||||
Hinzufügen
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if (!canEditItems()) {
|
||||
<div class="inline-empty">
|
||||
<mat-icon aria-hidden="true">save</mat-icon>
|
||||
<span>Speichere das Template, bevor du Items hinzufügst.</span>
|
||||
<span>Speichere das Template, bevor du Items hinzufuegst.</span>
|
||||
</div>
|
||||
} @else if (template()?.items?.length) {
|
||||
<ul
|
||||
|
||||
@@ -76,6 +76,56 @@
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.share-search-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.share-results,
|
||||
.collaborator-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.share-results li,
|
||||
.collaborator-list li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface));
|
||||
}
|
||||
|
||||
.share-results span,
|
||||
.collaborator-list strong,
|
||||
.collaborator-list span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collaborator-list div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.collaborator-list span {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.share-results mat-progress-spinner,
|
||||
.collaborator-list mat-progress-spinner {
|
||||
display: inline-flex;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.state-card mat-card-content {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
|
||||
@@ -12,6 +12,8 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { AuthService } from '../../auth/auth.service';
|
||||
import { PublicUserSearchResult } from '../../auth/auth.models';
|
||||
import { getAuthErrorMessage } from '../../auth/error-message';
|
||||
import { OnboardingService } from '../../onboarding/onboarding.service';
|
||||
import { ConfirmDeleteDialogComponent } from '../confirm-delete-dialog/confirm-delete-dialog.component';
|
||||
@@ -44,6 +46,7 @@ import { TemplatesService } from '../templates.service';
|
||||
export class TemplateDetailComponent implements OnInit {
|
||||
private readonly formBuilder = inject(NonNullableFormBuilder);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
@@ -60,6 +63,11 @@ export class TemplateDetailComponent implements OnInit {
|
||||
protected readonly copyingTemplate = signal(false);
|
||||
protected readonly reordering = signal(false);
|
||||
protected readonly errorMessage = signal<string | null>(null);
|
||||
protected readonly shareSearchTerm = signal('');
|
||||
protected readonly shareSearchResults = signal<PublicUserSearchResult[]>([]);
|
||||
protected readonly searchingUsers = signal(false);
|
||||
protected readonly sharingUserId = signal<string | null>(null);
|
||||
protected readonly removingShareUserId = signal<string | null>(null);
|
||||
|
||||
protected readonly templateForm = this.formBuilder.group({
|
||||
name: ['', [Validators.required]],
|
||||
@@ -71,6 +79,22 @@ export class TemplateDetailComponent implements OnInit {
|
||||
required: [true],
|
||||
});
|
||||
protected readonly canEditItems = computed(() => Boolean(this.template()?.id));
|
||||
protected readonly canManageShares = computed(
|
||||
() => this.template()?.accessRole === 'owner' && !this.isCreateMode(),
|
||||
);
|
||||
protected readonly canDeleteTemplate = computed(
|
||||
() => this.template()?.accessRole === 'owner' && !this.isCreateMode(),
|
||||
);
|
||||
protected readonly availableShareSearchResults = computed(() => {
|
||||
const template = this.template();
|
||||
const collaboratorIds = new Set(
|
||||
template?.collaborators.map((collaborator) => collaborator.id) ?? [],
|
||||
);
|
||||
|
||||
return this.shareSearchResults().filter(
|
||||
(user) => user.id !== template?.ownerId && !collaboratorIds.has(user.id),
|
||||
);
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isCreateMode.set(this.templateId() === null);
|
||||
@@ -292,11 +316,7 @@ export class TemplateDetailComponent implements OnInit {
|
||||
const templateId = this.templateId();
|
||||
const template = this.template();
|
||||
|
||||
if (
|
||||
!templateId ||
|
||||
!template ||
|
||||
this.deletingTemplate()
|
||||
) {
|
||||
if (!templateId || !template || !this.canDeleteTemplate() || this.deletingTemplate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -338,6 +358,77 @@ export class TemplateDetailComponent implements OnInit {
|
||||
await this.router.navigateByUrl('/templates');
|
||||
}
|
||||
|
||||
protected searchShareUsers(term: string): void {
|
||||
this.shareSearchTerm.set(term);
|
||||
|
||||
if (term.trim().length < 2) {
|
||||
this.shareSearchResults.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.searchingUsers.set(true);
|
||||
this.authService
|
||||
.searchUsers(term)
|
||||
.pipe(finalize(() => this.searchingUsers.set(false)))
|
||||
.subscribe({
|
||||
next: (users) => this.shareSearchResults.set(users),
|
||||
error: (error: unknown) => {
|
||||
this.shareSearchResults.set([]);
|
||||
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected shareWithUser(user: PublicUserSearchResult): void {
|
||||
const templateId = this.templateId();
|
||||
|
||||
if (!templateId || this.sharingUserId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sharingUserId.set(user.id);
|
||||
this.templatesService
|
||||
.shareTemplate(templateId, user.id)
|
||||
.pipe(finalize(() => this.sharingUserId.set(null)))
|
||||
.subscribe({
|
||||
next: (template) => {
|
||||
this.setTemplate(template);
|
||||
this.shareSearchTerm.set('');
|
||||
this.shareSearchResults.set([]);
|
||||
this.snackBar.open('Template geteilt.', 'OK', { duration: 2500 });
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected removeCollaborator(userId: string): void {
|
||||
const templateId = this.templateId();
|
||||
|
||||
if (!templateId || this.removingShareUserId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.removingShareUserId.set(userId);
|
||||
this.templatesService
|
||||
.removeShare(templateId, userId)
|
||||
.pipe(finalize(() => this.removingShareUserId.set(null)))
|
||||
.subscribe({
|
||||
next: (template) => {
|
||||
this.setTemplate(template);
|
||||
this.snackBar.open('Freigabe entfernt.', 'OK', { duration: 2500 });
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected displayUser(user: { name?: string; email: string }): string {
|
||||
return user.name ? `${user.name} (${user.email})` : user.email;
|
||||
}
|
||||
|
||||
private setTemplate(template: ListTemplate): void {
|
||||
this.template.set(template);
|
||||
this.templateForm.reset({
|
||||
|
||||
@@ -41,7 +41,12 @@
|
||||
<mat-card class="template-card" appearance="outlined">
|
||||
<mat-card-header>
|
||||
<mat-card-title>{{ template.name }}</mat-card-title>
|
||||
<mat-card-subtitle>{{ kindLabel(template.kind) }}</mat-card-subtitle>
|
||||
<mat-card-subtitle>
|
||||
{{ kindLabel(template.kind) }}
|
||||
@if (template.accessRole === 'collaborator') {
|
||||
- geteilt von {{ template.ownerName || template.ownerEmail || 'Owner' }}
|
||||
}
|
||||
</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
@@ -58,6 +63,12 @@
|
||||
<mat-icon aria-hidden="true">schedule</mat-icon>
|
||||
{{ template.updatedAt | date: 'dd.MM.yyyy' }}
|
||||
</span>
|
||||
@if (template.collaborators.length > 0) {
|
||||
<span>
|
||||
<mat-icon aria-hidden="true">group</mat-icon>
|
||||
{{ template.collaborators.length }} Mitwirkende
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (template.items.length > 0) {
|
||||
@@ -92,6 +103,7 @@
|
||||
<mat-icon aria-hidden="true">edit</mat-icon>
|
||||
Bearbeiten
|
||||
</a>
|
||||
@if (template.accessRole === 'owner') {
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
@@ -105,6 +117,7 @@
|
||||
<mat-icon aria-hidden="true">delete</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
@@ -14,14 +14,27 @@ export interface ListTemplateItem {
|
||||
export interface ListTemplate {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
ownerName?: string;
|
||||
ownerEmail?: string;
|
||||
accessRole: ListTemplateAccessRole;
|
||||
name: string;
|
||||
description?: string;
|
||||
kind: ListTemplateKind;
|
||||
items: ListTemplateItem[];
|
||||
collaborators: ListTemplateCollaborator[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type ListTemplateAccessRole = 'owner' | 'collaborator';
|
||||
|
||||
export interface ListTemplateCollaborator {
|
||||
id: string;
|
||||
name?: string;
|
||||
email: string;
|
||||
role: 'collaborator';
|
||||
}
|
||||
|
||||
export interface UserListItem {
|
||||
id: string;
|
||||
sourceTemplateItemId?: string;
|
||||
@@ -38,12 +51,21 @@ export interface UserListItem {
|
||||
export interface UserList {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
ownerName?: string;
|
||||
ownerEmail?: string;
|
||||
accessRole?: 'owner' | 'collaborator';
|
||||
sourceTemplateId?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
kind: ListTemplateKind;
|
||||
reminderAt?: string | null;
|
||||
items: UserListItem[];
|
||||
collaborators?: Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
email: string;
|
||||
role: 'collaborator';
|
||||
}>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,18 @@ export class TemplatesService {
|
||||
return this.http.delete<{ message: string }>(`${this.apiUrl}/${templateId}`);
|
||||
}
|
||||
|
||||
shareTemplate(templateId: string, userId: string): Observable<ListTemplate> {
|
||||
return this.http.post<ListTemplate>(`${this.apiUrl}/${templateId}/shares`, {
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
removeShare(templateId: string, userId: string): Observable<ListTemplate> {
|
||||
return this.http.delete<ListTemplate>(
|
||||
`${this.apiUrl}/${templateId}/shares/${userId}`,
|
||||
);
|
||||
}
|
||||
|
||||
createListFromTemplate(
|
||||
templateId: string,
|
||||
data: CreateListFromTemplateRequest = {},
|
||||
|
||||
Reference in New Issue
Block a user