Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/studio/ui/src/app/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { RouterView, useRoute } from 'vue-router'

import DialogHost from '@/features/dialogs/DialogHost.vue'
import { useDialog } from '@/features/dialogs/use-dialog'
import { setAPIDialogHandler } from '@/features/api/client'
import ToastHost from '@/features/toasts/ToastHost.vue'
import { useToast } from '@/features/toasts/use-toast'
import { setAPIDialogHandler, setAPIToastHandler } from '@/features/api/client'
import { iconForEntity } from '@/features/metadata/entity-icons'
import { routeParam, RouteName } from '@/router/routes'
import { useMetadataEntitiesQuery } from '@/features/metadata/metadata.query'
Expand All @@ -18,12 +20,17 @@ const route = useRoute()
const authStore = useAuthStore()
const navigationStore = useNavigationStore()
const dialog = useDialog()
const toast = useToast()

setAPIDialogHandler((request) => {
void dialog.open(request)
})
setAPIToastHandler((request) => {
toast.show(request)
})
onUnmounted(() => {
setAPIDialogHandler(null)
setAPIToastHandler(null)
})

const usesShell = computed(() => !route.meta.public)
Expand Down Expand Up @@ -117,6 +124,7 @@ function humanizeEntity(value: string): string {
<RouterView :key="shellRouteViewKey" />
</Shell>
<DialogHost />
<ToastHost />
</template>

<style scoped>
Expand Down
97 changes: 97 additions & 0 deletions apps/studio/ui/src/features/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ApiClientError,
apiRequest,
setAPIDialogHandler,
setAPIToastHandler,
type ApiErrorEnvelope,
type DataEnvelope,
} from './client.ts'
Expand Down Expand Up @@ -54,6 +55,27 @@ test('apiRequest emits successful response dialogs', async (t) => {
assert.equal(observedTitle, 'Saved')
})

test('apiRequest emits successful response toasts', async (t) => {
const originalFetch = globalThis.fetch
t.after(() => {
globalThis.fetch = originalFetch
setAPIToastHandler(null)
})

let observedTitle = ''
setAPIToastHandler((toast) => {
observedTitle = toast.title
})
globalThis.fetch = (async () => new Response(JSON.stringify({
data: { ok: true },
toast: { title: 'Saved' },
}), { status: 200 })) as typeof fetch

await apiRequest<DataEnvelope<{ ok: boolean }>, TestApiError>('/api/test', { method: 'GET' }, requestOptions())

assert.equal(observedTitle, 'Saved')
})

test('apiRequest ignores dialog handler failures on successful responses', async (t) => {
const originalFetch = globalThis.fetch
t.after(() => {
Expand All @@ -74,6 +96,26 @@ test('apiRequest ignores dialog handler failures on successful responses', async
assert.deepEqual(payload.data, { ok: true })
})

test('apiRequest ignores toast handler failures on successful responses', async (t) => {
const originalFetch = globalThis.fetch
t.after(() => {
globalThis.fetch = originalFetch
setAPIToastHandler(null)
})

setAPIToastHandler(() => {
throw new Error('toast failed')
})
globalThis.fetch = (async () => new Response(JSON.stringify({
data: { ok: true },
toast: { title: 'Saved' },
}), { status: 200 })) as typeof fetch

const payload = await apiRequest<DataEnvelope<{ ok: boolean }>, TestApiError>('/api/test', { method: 'GET' }, requestOptions())

assert.deepEqual(payload.data, { ok: true })
})

test('apiRequest maps error envelopes through the domain error class', async (t) => {
const originalFetch = globalThis.fetch
t.after(() => {
Expand Down Expand Up @@ -126,6 +168,32 @@ test('apiRequest emits error response dialogs before throwing', async (t) => {
assert.equal(observedTitle, 'Access denied')
})

test('apiRequest emits error response toasts before throwing', async (t) => {
const originalFetch = globalThis.fetch
t.after(() => {
globalThis.fetch = originalFetch
setAPIToastHandler(null)
})

let observedTitle = ''
setAPIToastHandler((toast) => {
observedTitle = toast.title
})
globalThis.fetch = (async () => new Response(JSON.stringify({
error: {
code: 'forbidden',
message: 'permission denied',
toast: { title: 'Access denied' },
},
}), { status: 403 })) as typeof fetch

await assert.rejects(
apiRequest<DataEnvelope<unknown>, TestApiError>('/api/test', { method: 'GET' }, requestOptions()),
TestApiError,
)
assert.equal(observedTitle, 'Access denied')
})

test('apiRequest preserves mapped errors when dialog handler fails', async (t) => {
const originalFetch = globalThis.fetch
t.after(() => {
Expand Down Expand Up @@ -155,6 +223,35 @@ test('apiRequest preserves mapped errors when dialog handler fails', async (t) =
)
})

test('apiRequest preserves mapped errors when toast handler fails', async (t) => {
const originalFetch = globalThis.fetch
t.after(() => {
globalThis.fetch = originalFetch
setAPIToastHandler(null)
})

setAPIToastHandler(() => {
throw new Error('toast failed')
})
globalThis.fetch = (async () => new Response(JSON.stringify({
error: {
code: 'forbidden',
message: 'permission denied',
toast: { title: 'Access denied' },
},
}), { status: 403 })) as typeof fetch

await assert.rejects(
apiRequest<DataEnvelope<unknown>, TestApiError>('/api/test', { method: 'GET' }, requestOptions()),
(error) => {
assert.equal(error instanceof TestApiError, true)
assert.equal((error as TestApiError).code, 'forbidden')
assert.equal((error as Error).message, 'mapped: permission denied')
return true
},
)
})

test('apiRequest reports invalid JSON with the domain error class', async (t) => {
const originalFetch = globalThis.fetch
t.after(() => {
Expand Down
25 changes: 24 additions & 1 deletion apps/studio/ui/src/features/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import type { StudioDialogRequest } from '../dialogs/dialogs.store'
import type { StudioToastRequest } from '../toasts/toasts.store'

export type APIDialogHandler = (dialog: StudioDialogRequest) => void
export type APIToastHandler = (toast: StudioToastRequest) => void

let apiDialogHandler: APIDialogHandler | null = null
let apiToastHandler: APIToastHandler | null = null

export function setAPIDialogHandler(handler: APIDialogHandler | null) {
apiDialogHandler = handler
}

export function setAPIToastHandler(handler: APIToastHandler | null) {
apiToastHandler = handler
}

export type ApiErrorBody = {
code?: string
message?: string
details?: Record<string, unknown>
dialog?: StudioDialogRequest
toast?: StudioToastRequest
}

export type ApiErrorEnvelope = {
Expand All @@ -22,12 +30,14 @@ export type ApiErrorEnvelope = {
export type DataEnvelope<T> = {
data: T
dialog?: StudioDialogRequest
toast?: StudioToastRequest
}

export type ListEnvelope<T, M = unknown> = {
data: T
meta: M
dialog?: StudioDialogRequest
toast?: StudioToastRequest
}

export class ApiClientError extends Error {
Expand Down Expand Up @@ -68,10 +78,13 @@ export async function apiRequest<TEnvelope, TError extends ApiClientError>(

if (!response.ok) {
emitAPIDialog(payload.error?.dialog)
emitAPIToast(payload.error?.toast)
throw new options.error(payload.error?.code ?? options.fallbackCode, options.message(payload), payload.error?.details)
}

emitAPIDialog((payload as TEnvelope & { dialog?: StudioDialogRequest }).dialog)
const successPayload = payload as TEnvelope & { dialog?: StudioDialogRequest, toast?: StudioToastRequest }
emitAPIDialog(successPayload.dialog)
emitAPIToast(successPayload.toast)
return payload
}

Expand All @@ -85,6 +98,16 @@ function emitAPIDialog(dialog: StudioDialogRequest | undefined) {
}
}

function emitAPIToast(toast: StudioToastRequest | undefined) {
if (toast) {
try {
apiToastHandler?.(toast)
} catch {
// Toast rendering is best-effort; it must not change API request semantics.
}
}
}

async function parseAPIJSON<TEnvelope, TError extends ApiClientError = ApiClientError>(
response: Response,
ErrorClass: ApiErrorClass<TError>,
Expand Down
130 changes: 130 additions & 0 deletions apps/studio/ui/src/features/toasts/ToastHost.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed } from 'vue'
import { X } from '@lucide/vue'

import { useToastStore, type StudioToastType } from './toasts.store'

const toastStore = useToastStore()
const visibleToasts = computed(() => toastStore.toasts)

function dismiss(id: number) {
toastStore.dismiss(id)
}

function labelForType(type: StudioToastType): string {
return type === 'danger' ? 'Error' : type
}
</script>

<template>
<div v-if="visibleToasts.length" class="studio-toast-host" aria-live="polite" aria-atomic="false">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep toast live region mounted

When the toast list is empty, this v-if removes the aria-live container, so the first toast after an idle period is inserted as part of a newly created live region rather than as a change to an existing one. Screen-reader users can miss initial or single success/error toasts in that scenario; keep the live region mounted and conditionally render only the toast children or hide the empty container instead.

Useful? React with 👍 / 👎.

<article
v-for="toast in visibleToasts"
:key="toast.id"
class="studio-toast"
:data-type="toast.type"
>
<div class="studio-toast__body">
<div class="studio-toast__kicker">
{{ labelForType(toast.type) }}
</div>
<h2 class="studio-toast__title">
{{ toast.title }}
</h2>
<p v-if="toast.content" class="studio-toast__content">
{{ toast.content }}
</p>
</div>
<button class="studio-toast__close" type="button" aria-label="Dismiss toast" @click="dismiss(toast.id)">
<X :size="14" aria-hidden="true" />
</button>
</article>
</div>
</template>

<style scoped>
.studio-toast-host {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 70;
display: grid;
gap: 8px;
width: min(calc(100vw - 32px), 340px);
}

.studio-toast {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
border: 1px solid var(--studio-border);
border-left: 3px solid var(--studio-text-subtle);
border-radius: var(--studio-radius-sheet);
background: var(--studio-surface);
box-shadow: var(--studio-shadow);
padding: 12px;
}

.studio-toast[data-type='success'] {
border-left-color: var(--studio-success);
}

.studio-toast[data-type='warning'] {
border-left-color: var(--studio-warning);
}

.studio-toast[data-type='danger'] {
border-left-color: var(--studio-danger);
}

.studio-toast__body {
display: grid;
gap: 4px;
min-width: 0;
}

.studio-toast__kicker {
color: var(--studio-text-subtle);
font-size: 10px;
font-weight: 700;
line-height: 1;
text-transform: uppercase;
}

.studio-toast__title {
color: var(--studio-text);
font-size: 13px;
font-weight: 700;
line-height: 1.35;
margin: 0;
}

.studio-toast__content {
color: var(--studio-text-muted);
font-size: 12px;
line-height: 1.45;
margin: 0;
white-space: pre-wrap;
}

.studio-toast__close {
display: inline-grid;
width: 24px;
height: 24px;
place-items: center;
border: 0;
border-radius: var(--studio-radius-control);
background: transparent;
color: var(--studio-text-subtle);
}

.studio-toast__close:hover {
background: var(--studio-surface-raised);
color: var(--studio-text);
}

.studio-toast__close:focus-visible {
outline: 2px solid var(--studio-focus);
outline-offset: 2px;
}
</style>
Loading