diff --git a/apps/studio/ui/src/app/App.vue b/apps/studio/ui/src/app/App.vue index af4bbcd..a403503 100644 --- a/apps/studio/ui/src/app/App.vue +++ b/apps/studio/ui/src/app/App.vue @@ -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' @@ -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) @@ -117,6 +124,7 @@ function humanizeEntity(value: string): string { + diff --git a/apps/studio/ui/src/features/toasts/toasts.store.test.ts b/apps/studio/ui/src/features/toasts/toasts.store.test.ts new file mode 100644 index 0000000..d4ad1fb --- /dev/null +++ b/apps/studio/ui/src/features/toasts/toasts.store.test.ts @@ -0,0 +1,35 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createPinia, setActivePinia } from 'pinia' + +import { useToastStore } from './toasts.store.ts' + +test('toast store defaults and dismisses toasts', () => { + setActivePinia(createPinia()) + const store = useToastStore() + + store.show({ title: 'Saved' }) + assert.equal(store.toasts.length, 1) + assert.equal(store.toasts[0].type, 'info') + assert.equal(store.toasts[0].duration, 4000) + + store.dismiss(store.toasts[0].id) + assert.equal(store.toasts.length, 0) +}) + +test('toast store validates title and duration', () => { + setActivePinia(createPinia()) + const store = useToastStore() + + assert.throws(() => store.show({ title: '' }), /toast title is required/) + assert.throws(() => store.show({ title: 'Bad', duration: -1 }), /duration/) +}) + +test('toast store keeps zero duration sticky', () => { + setActivePinia(createPinia()) + const store = useToastStore() + + store.show({ title: 'Stay open', duration: 0 }) + + assert.equal(store.toasts[0].duration, 0) +}) diff --git a/apps/studio/ui/src/features/toasts/toasts.store.ts b/apps/studio/ui/src/features/toasts/toasts.store.ts new file mode 100644 index 0000000..c42c616 --- /dev/null +++ b/apps/studio/ui/src/features/toasts/toasts.store.ts @@ -0,0 +1,62 @@ +import { defineStore } from 'pinia' + +const defaultDuration = 4000 + +export type StudioToastType = 'info' | 'success' | 'warning' | 'danger' + +export type StudioToastRequest = { + title: string + content?: string + type?: StudioToastType + duration?: number +} + +export type StudioToast = Required & { + id: number +} + +let nextToastID = 1 +const toastTimers = new Map>() + +export const useToastStore = defineStore('toasts', { + state: () => ({ + toasts: [] as StudioToast[], + }), + + actions: { + show(request: StudioToastRequest) { + const toast = normalizeToast(request) + this.toasts.push(toast) + if (toast.duration > 0) { + toastTimers.set(toast.id, setTimeout(() => this.dismiss(toast.id), toast.duration)) + } + }, + + dismiss(id: number) { + const timer = toastTimers.get(id) + if (timer) { + clearTimeout(timer) + toastTimers.delete(id) + } + this.toasts = this.toasts.filter((toast) => toast.id !== id) + }, + }, +}) + +function normalizeToast(request: StudioToastRequest): StudioToast { + const title = request.title.trim() + if (!title) { + throw new Error('toast title is required') + } + if (request.duration !== undefined && request.duration < 0) { + throw new Error('toast duration must not be negative') + } + + return { + id: nextToastID++, + title, + content: request.content?.trim() ?? '', + type: request.type ?? 'info', + duration: request.duration ?? defaultDuration, + } +} diff --git a/apps/studio/ui/src/features/toasts/use-toast.ts b/apps/studio/ui/src/features/toasts/use-toast.ts new file mode 100644 index 0000000..7b420a0 --- /dev/null +++ b/apps/studio/ui/src/features/toasts/use-toast.ts @@ -0,0 +1,15 @@ +import { useToastStore, type StudioToastRequest } from './toasts.store' + +export function useToast() { + const store = useToastStore() + + return { + show: (request: StudioToastRequest) => store.show(request), + success: (title: string, content?: string) => store.show({ title, content, type: 'success' }), + error: (title: string, content?: string) => store.show({ title, content, type: 'danger' }), + warning: (title: string, content?: string) => store.show({ title, content, type: 'warning' }), + info: (title: string, content?: string) => store.show({ title, content, type: 'info' }), + } +} + +export type { StudioToastRequest } diff --git a/apps/studio/ui/src/pages/RecordFormPage.vue b/apps/studio/ui/src/pages/RecordFormPage.vue index a50a71e..bb140a9 100644 --- a/apps/studio/ui/src/pages/RecordFormPage.vue +++ b/apps/studio/ui/src/pages/RecordFormPage.vue @@ -5,6 +5,7 @@ import { Plus, RotateCcw, Save, Trash2 } from '@lucide/vue' import { ErrorState, Spinner } from '@/design' import { useDialog } from '@/features/dialogs/use-dialog' +import { useToast } from '@/features/toasts/use-toast' import type { MetadataEntityMeta, MetadataField } from '@/features/metadata/metadata.api' import { useMetadataEntityMetaQuery } from '@/features/metadata/metadata.query' import { @@ -37,6 +38,7 @@ type ConvertedValue = { const router = useRouter() const dialog = useDialog() +const toast = useToast() const entityMetaQuery = useMetadataEntityMetaQuery(() => props.entity) const draft = ref({}) @@ -314,6 +316,7 @@ async function saveRecord() { }) resetToRecord(record) + toast.success(isNew.value ? 'Record created' : 'Record saved') const nextName = typeof record.name === 'string' ? record.name : '' if (!isSingle.value && nextName && (isNew.value || nextName !== props.recordName)) { await router.replace({ name: RouteName.RecordDetail, params: { entity: props.entity, recordName: nextName } }) @@ -351,6 +354,7 @@ async function deleteRecord() { recordName: props.recordName ?? '', id: currentRecordID(), }) + toast.success('Record deleted') await router.replace({ name: RouteName.EntityRecords, params: { entity: props.entity } }) } catch { // TanStack owns the mutation error for display. diff --git a/docs/index.md b/docs/index.md index 791a285..cb39628 100644 --- a/docs/index.md +++ b/docs/index.md @@ -44,6 +44,7 @@ Use this index to find the right document for the task. The docs are kept in the - [Studio](studio.md) explains the first-party global UI app, route model, and design responsibilities. - [Dialogs](dialogs.md) records the proposed shared Studio dialog API. +- [Toasts](toasts.md) records the proposed shared Studio toast API. ## Maintainers diff --git a/docs/toasts.md b/docs/toasts.md new file mode 100644 index 0000000..410529c --- /dev/null +++ b/docs/toasts.md @@ -0,0 +1,87 @@ +# Toasts + +Status: v1 decision log. + +Studio needs a small non-blocking message surface for short-lived feedback. + +## Decided + +- Use `toast` as the public Studio term. +- Use `useToast()` as the frontend API name. +- Toasts are transient, non-blocking messages. +- Dialogs stay blocking; toasts do not replace dialogs. +- Persistent system items are future notifications, not v1 toasts. +- Toasts render in the bottom-right corner. +- Toasts stack newest-first. +- Newest toast appears closest to the bottom-right edge; older toasts move upward. +- Toasts auto-dismiss by default. +- Toasts can be manually dismissed. +- Manual dismissal means the user closes the toast; v1 does not expose programmatic close handles. +- V1 toast types are `info`, `success`, `warning`, and `danger`. +- V1 toast requests require `title`. +- V1 toast requests may include `content`, `type`, and `duration`. +- Empty `title` is invalid. +- Empty `content` is allowed. +- `type` defaults to `info`. +- `duration` is milliseconds. +- Missing `duration` uses the Studio default duration. +- Default duration is `4000` ms. +- `duration: 0` means sticky until dismissed. +- Negative `duration` is invalid. +- V1 toasts do not include actions. +- If a message needs required user action, use a dialog. +- `useToast()` exposes `show`, `success`, `error`, `warning`, and `info`. +- `show` accepts the full toast request shape. +- `success`, `error`, `warning`, and `info` are convenience methods for common toast types. +- `error` creates a `danger` toast. +- Server responses may include a single toast intent in v1. +- Server `toast` is best-effort and must not change API request semantics. +- Use `dygo.Toast` as the public SDK type name. +- Server response envelopes should use a single `toast` field in v1. +- Success responses put `toast` beside `data`. +- Error responses put `toast` inside the existing `error` envelope. +- Do not add `toasts` arrays in v1. +- Duplicate identical toasts are allowed in v1. +- Toasts are not persisted. +- Toasts are not written to Activity. + +## Shape + +```ts +type StudioToastType = "info" | "success" | "warning" | "danger" + +type StudioToastRequest = { + title: string + content?: string + type?: StudioToastType + duration?: number +} + +type UseToast = { + show(request: StudioToastRequest): void + success(title: string, content?: string): void + error(title: string, content?: string): void + warning(title: string, content?: string): void + info(title: string, content?: string): void +} +``` + +## SDK Shape + +```go +type ToastType string + +const ( + ToastInfo ToastType = "info" + ToastSuccess ToastType = "success" + ToastWarning ToastType = "warning" + ToastDanger ToastType = "danger" +) + +type Toast struct { + Title string `json:"title"` + Content string `json:"content,omitempty"` + Type ToastType `json:"type,omitempty"` + Duration *int `json:"duration,omitempty"` +} +``` diff --git a/internal/server/server.go b/internal/server/server.go index 97f53f5..1fa4815 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -428,6 +428,7 @@ func writeAuthError(w http.ResponseWriter, err error) { type dataEnvelope struct { Data any `json:"data"` Dialog *dygo.Dialog `json:"dialog,omitempty"` + Toast *dygo.Toast `json:"toast,omitempty"` } type bootDefaults struct { @@ -512,6 +513,7 @@ type listEnvelope struct { Data any `json:"data"` Meta any `json:"meta"` Dialog *dygo.Dialog `json:"dialog,omitempty"` + Toast *dygo.Toast `json:"toast,omitempty"` } type recordListMeta struct { @@ -530,6 +532,7 @@ type apiError struct { Message string `json:"message"` Details map[string]any `json:"details,omitempty"` Dialog *dygo.Dialog `json:"dialog,omitempty"` + Toast *dygo.Toast `json:"toast,omitempty"` } func writeErrorEnvelope(w http.ResponseWriter, status int, code string, message string, details map[string]any) { diff --git a/pkg/dygo/dialogs.go b/pkg/dygo/dialogs.go index 599890f..5dd0d7a 100644 --- a/pkg/dygo/dialogs.go +++ b/pkg/dygo/dialogs.go @@ -35,3 +35,21 @@ type Dialog struct { Actions []DialogAction `json:"actions,omitempty"` Dismissible *bool `json:"dismissible,omitempty"` } + +// ToastType controls the visual intent of a Studio toast. +type ToastType string + +const ( + ToastInfo ToastType = "info" + ToastSuccess ToastType = "success" + ToastWarning ToastType = "warning" + ToastDanger ToastType = "danger" +) + +// Toast is a server-provided Studio toast intent. +type Toast struct { + Title string `json:"title"` + Content string `json:"content,omitempty"` + Type ToastType `json:"type,omitempty"` + Duration *int `json:"duration,omitempty"` +} diff --git a/pkg/dygo/dialogs_test.go b/pkg/dygo/dialogs_test.go index c9e7ec2..64ca5bd 100644 --- a/pkg/dygo/dialogs_test.go +++ b/pkg/dygo/dialogs_test.go @@ -25,3 +25,36 @@ func TestDialogJSONIncludesExplicitFalseDismissible(t *testing.T) { t.Fatalf("Dialog JSON = %s, want %s", data, want) } } + +func TestToastJSONIncludesDuration(t *testing.T) { + duration := 4000 + data, err := json.Marshal(Toast{ + Title: "Saved", + Type: ToastSuccess, + Duration: &duration, + }) + if err != nil { + t.Fatalf("Marshal(Toast) error = %v", err) + } + + want := `{"title":"Saved","type":"success","duration":4000}` + if string(data) != want { + t.Fatalf("Toast JSON = %s, want %s", data, want) + } +} + +func TestToastJSONIncludesExplicitZeroDuration(t *testing.T) { + duration := 0 + data, err := json.Marshal(Toast{ + Title: "Stay open", + Duration: &duration, + }) + if err != nil { + t.Fatalf("Marshal(Toast) error = %v", err) + } + + want := `{"title":"Stay open","duration":0}` + if string(data) != want { + t.Fatalf("Toast JSON = %s, want %s", data, want) + } +}