Workspace UI — WorkspaceStore, Member Management, and Slug Preview

The Angular workspace UI is the entry point of the Task Manager for every user session — the workspace selector, the sidebar navigation, and the member management screens that control who can access what. Building these components closes the loop between the workspace backend (Chapter 25) and the user experience: workspace creation, invitation sending, member list with role badges, and the role change controls that workspace admins use daily. These screens also demonstrate the Angular patterns for asynchronous workflows where multi-step processes (create workspace → invite first member → await acceptance) must remain coherent across navigation.

Workspace UI Component Architecture

Component Route Responsibility
WorkspaceListComponent /workspaces All user’s workspaces — create new button
WorkspaceCreateComponent /workspaces/new Name, description, slug preview form
WorkspaceSidebarComponent Shell (persistent) Current workspace switcher + nav links
WorkspaceMembersComponent /w/:slug/members Member list with roles + invite form
WorkspaceSettingsComponent /w/:slug/settings Edit name/description, danger zone (delete)
InvitationAcceptComponent /invitations/:token Accept/decline workspace invitation
Note: The workspace sidebar must display the current workspace’s name and the user’s role within it on every page. Store the current workspace in a WorkspaceStore signal — loaded once when the workspace guard resolves the slug to a workspace document. All sidebar and header components read from this store signal rather than making separate API calls. When the user switches workspaces (navigating to /w/new-slug), the workspace guard re-runs and updates the store, and all components that read the signal re-render automatically.
Tip: Add a real-time slug preview to the workspace creation form. As the user types the workspace name, compute the slug client-side (using the same slugify logic as the server) and display it: “Your workspace URL will be taskmanager.io/w/acme-corp“. Debounce with a 500ms delay, then call the GET /workspaces/check-slug?slug=acme-corp endpoint to verify availability. Show “✓ Available” or “✗ Already taken — try acme-corp-2”. This eliminates the frustrating “slug already taken” error after form submission.
Warning: The workspace delete operation is irreversible — it destroys all tasks, attachments, member associations, and notification history for that workspace. Add a confirmation dialog that requires the user to type the workspace name to confirm: <input placeholder="Type 'acme-corp' to confirm">. Only enable the final delete button when the input matches the workspace slug exactly. This pattern (type to confirm) is the standard UX for irreversible high-stakes actions — GitHub uses it for repository deletion.

Complete Workspace UI Implementation

// ── core/stores/workspace.store.ts ────────────────────────────────────────
import { Injectable, signal, computed, inject } from '@angular/core';
import { WorkspaceService }  from '../../features/workspaces/services/workspace.service';
import { Workspace, WorkspaceMember, MemberRole } from '@taskmanager/shared';
import { AuthStore }         from './auth.store';

@Injectable({ providedIn: 'root' })
export class WorkspaceStore {
    private service   = inject(WorkspaceService);
    private authStore = inject(AuthStore);

    private _workspaces       = signal<Workspace[]>([]);
    private _current          = signal<Workspace | null>(null);
    private _loading          = signal(false);

    readonly workspaces       = this._workspaces.asReadonly();
    readonly current          = this._current.asReadonly();
    readonly loading          = this._loading.asReadonly();

    readonly currentMembers   = computed(() => this._current()?.members ?? []);

    // Current user's role in the current workspace
    readonly myRole = computed((): MemberRole | null => {
        const ws     = this._current();
        const userId = this.authStore.user()?._id;
        if (!ws || !userId) return null;
        return ws.members.find(m => m.userId === userId)?.role ?? null;
    });

    readonly canManageMembers = computed(() =>
        ['owner', 'admin'].includes(this.myRole() ?? '')
    );
    readonly isOwner = computed(() => this.myRole() === 'owner');

    loadAll(): void {
        this._loading.set(true);
        this.service.getMyWorkspaces().subscribe({
            next:     ws => { this._workspaces.set(ws); this._loading.set(false); },
            error:    ()  => this._loading.set(false),
        });
    }

    setCurrent(workspace: Workspace): void {
        this._current.set(workspace);
    }

    addWorkspace(ws: Workspace): void {
        this._workspaces.update(list => [ws, ...list]);
        this._current.set(ws);
    }

    updateCurrentMember(userId: string, newRole: MemberRole): void {
        this._current.update(ws => {
            if (!ws) return ws;
            return {
                ...ws,
                members: ws.members.map(m =>
                    m.userId === userId ? { ...m, role: newRole } : m
                ),
            };
        });
    }

    removeMember(userId: string): void {
        this._current.update(ws => {
            if (!ws) return ws;
            return { ...ws, members: ws.members.filter(m => m.userId !== userId) };
        });
    }
}

// ── features/workspaces/components/workspace-members.component.ts ──────────
@Component({
    selector:   'tm-workspace-members',
    standalone: true,
    changeDetection: ChangeDetectionStrategy.OnPush,
    imports: [ReactiveFormsModule, CommonModule, RoleBadgeComponent, AvatarComponent],
    template: `
        <div class="members-page">
            <h2>Members ({{ store.currentMembers().length }})</h2>

            <!-- Invite form (admin/owner only) -->
            @if (wsStore.canManageMembers()) {
                <form [formGroup]="inviteForm" (ngSubmit)="sendInvite()"
                      class="invite-form">
                    <input formControlName="email"
                           type="email"
                           placeholder="colleague@company.com"
                           data-testid="invite-email">
                    <select formControlName="role">
                        <option value="viewer">Viewer</option>
                        <option value="member" selected>Member</option>
                        <option value="admin">Admin</option>
                    </select>
                    <button type="submit"
                            [disabled]="inviteForm.invalid || inviting()"
                            data-testid="invite-btn">
                        {{ inviting() ? 'Sending...' : 'Send Invite' }}
                    </button>
                </form>
            }

            <!-- Member list -->
            @for (member of wsStore.currentMembers(); track member.userId) {
                <div class="member-row">
                    <tm-avatar [userId]="member.userId" size="sm"></tm-avatar>
                    <span class="member-name">{{ getMemberName(member.userId) }}</span>
                    <tm-role-badge [role]="member.role"></tm-role-badge>

                    <!-- Role change (admin/owner only, not for self or owner) -->
                    @if (wsStore.canManageMembers() &&
                         member.userId !== authStore.user()?._id &&
                         member.role !== 'owner') {
                        <select [value]="member.role"
                                (change)="changeRole(member.userId, $any($event.target).value)">
                            <option value="viewer">Viewer</option>
                            <option value="member">Member</option>
                            @if (wsStore.isOwner()) {
                                <option value="admin">Admin</option>
                            }
                        </select>
                        <button class="btn btn--danger btn--sm"
                                (click)="removeMember(member.userId)">
                            Remove
                        </button>
                    }
                </div>
            }
        </div>
    `,
})
export class WorkspaceMembersComponent implements OnInit {
    private fb      = inject(FormBuilder);
    private service = inject(WorkspaceService);
    private toast   = inject(ToastService);
    wsStore         = inject(WorkspaceStore);
    authStore       = inject(AuthStore);

    inviting  = signal(false);
    inviteForm = this.fb.group({
        email: ['', [Validators.required, Validators.email]],
        role:  ['member'],
    });

    ngOnInit(): void {
        // Workspace and members already loaded by WorkspaceGuard
    }

    sendInvite(): void {
        if (this.inviteForm.invalid) return;
        this.inviting.set(true);

        this.service.inviteMember(
            this.wsStore.current()!._id,
            this.inviteForm.value as { email: string; role: string }
        ).subscribe({
            next: () => {
                this.toast.success('Invitation sent');
                this.inviteForm.reset({ role: 'member' });
                this.inviting.set(false);
            },
            error: err => {
                this.toast.error(err.error?.message ?? 'Failed to send invitation');
                this.inviting.set(false);
            },
        });
    }

    changeRole(userId: string, newRole: MemberRole): void {
        this.service.updateMemberRole(
            this.wsStore.current()!._id, userId, newRole
        ).subscribe({
            next: () => {
                this.wsStore.updateCurrentMember(userId, newRole);
                this.toast.success('Role updated');
            },
            error: err => this.toast.error(err.error?.message ?? 'Failed to update role'),
        });
    }

    removeMember(userId: string): void {
        if (!confirm('Remove this member from the workspace?')) return;
        this.service.removeMember(this.wsStore.current()!._id, userId).subscribe({
            next: () => {
                this.wsStore.removeMember(userId);
                this.toast.success('Member removed');
            },
            error: err => this.toast.error(err.error?.message ?? 'Failed to remove member'),
        });
    }

    getMemberName(userId: string): string {
        // In a real app, user details would be populated from a users store or the workspace populate
        return userId;
    }
}

How It Works

Step 1 — WorkspaceStore Provides Workspace Context to All Components

Setting the current workspace in WorkspaceStore._current from the workspace guard makes the workspace available to every component in the workspace feature without prop drilling or repeated API calls. The sidebar, the member list, the task list, and the settings page all inject WorkspaceStore and read wsStore.current(). The guard runs once per workspace navigation — all components within that workspace share the same loaded object.

Step 2 — Computed Role Signals Drive UI Visibility

canManageMembers and isOwner are computed signals derived from myRole. Template elements bound to these signals show or hide automatically when the workspace changes — if the user switches to a workspace where they are a Viewer, all admin controls disappear without any manual change detection. The role hierarchy is expressed once in computed signals, not scattered across conditional *ngIf checks in multiple templates.

Step 3 — Optimistic Member Updates Avoid Re-fetching the Workspace

After a role change or member removal, the store updates its local _current signal directly (wsStore.updateCurrentMember()) rather than re-fetching the workspace from the API. The template re-renders immediately with the correct new role badge. If the API call fails, an error toast is shown but the optimistic update is not rolled back — for role changes, the user can simply change it again. For removal, a rollback would be more important, but in this implementation a page refresh restores the correct state.

Step 4 — Admin Cannot See the Admin Option in Role Selector

The role change dropdown conditionally shows the Admin option only when wsStore.isOwner() is true. Admins see only Viewer and Member in the selector — they cannot promote anyone to Admin even through the UI. This mirrors the server-side permission check, creating defence in depth: both UI and API enforce the constraint. If a malicious user bypasses the UI and sends an admin-to-admin API request, the server rejects it independently.

Step 5 — Slug Preview Provides Immediate Feedback

Debouncing the workspace name input (350ms), computing the slug client-side, and checking availability via API creates a responsive feedback loop that prevents the most frustrating form failure: discovering a slug is taken after completing a multi-field form. The check endpoint (GET /workspaces/check-slug?slug=x) is fast (just a Workspace.exists({ slug }) query) and does not require authentication, making it safe to call on every debounced keystroke.

Quick Reference

Task Code
Current workspace inject(WorkspaceStore).current()
User’s role in workspace wsStore.myRole() — computed from members array
Can manage members wsStore.canManageMembers() — computed from role
Update member in store wsStore.updateCurrentMember(userId, newRole)
Remove member from store wsStore.removeMember(userId)
Invite member workspaceService.inviteMember(wsId, { email, role })
Change role workspaceService.updateMemberRole(wsId, userId, newRole)
Slug availability check GET /workspaces/check-slug?slug=x (debounced)

🧠 Test Yourself

A workspace admin views the member list. The role change dropdown for another member shows options: Viewer, Member (but NOT Admin). Why, and what must be true for the Admin option to appear?