diff --git a/alias.ts b/alias.ts index 636e0e28..96f5805c 100644 --- a/alias.ts +++ b/alias.ts @@ -101,7 +101,6 @@ export const alias = { '@devframes/plugin-messages/client': p('messages/src/client/index.ts'), '@devframes/plugin-messages/node': p('messages/src/node/index.ts'), '@devframes/plugin-messages/constants': p('messages/src/constants.ts'), - '@devframes/plugin-messages/types': p('messages/src/types.ts'), '@devframes/plugin-messages/rpc': p('messages/src/rpc/index.ts'), '@devframes/plugin-messages/cli': p('messages/src/cli.ts'), '@devframes/plugin-messages/vite': p('messages/src/vite.ts'), diff --git a/plugins/messages/package.json b/plugins/messages/package.json index e6da07c7..ac5efaab 100644 --- a/plugins/messages/package.json +++ b/plugins/messages/package.json @@ -27,7 +27,6 @@ "./constants": "./dist/constants.mjs", "./node": "./dist/node/index.mjs", "./rpc": "./dist/rpc/index.mjs", - "./types": "./dist/types.mjs", "./vite": "./dist/vite.mjs", "./package.json": "./package.json" }, @@ -75,6 +74,7 @@ "@vueuse/core": "catalog:frontend", "colorjs.io": "catalog:frontend", "devframe": "workspace:*", + "floating-vue": "catalog:frontend", "get-port-please": "catalog:deps", "h3": "catalog:deps", "storybook": "catalog:storybook", diff --git a/plugins/messages/src/client/App.vue b/plugins/messages/src/client/App.vue index eb68a4b5..91c104e4 100644 --- a/plugins/messages/src/client/App.vue +++ b/plugins/messages/src/client/App.vue @@ -1,6 +1,8 @@ diff --git a/plugins/messages/src/client/components/HashBadge.vue b/plugins/messages/src/client/components/HashBadge.vue deleted file mode 100644 index 44a7db03..00000000 --- a/plugins/messages/src/client/components/HashBadge.vue +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/plugins/messages/src/client/components/MessageDetail.vue b/plugins/messages/src/client/components/MessageDetail.vue new file mode 100644 index 00000000..056dc691 --- /dev/null +++ b/plugins/messages/src/client/components/MessageDetail.vue @@ -0,0 +1,171 @@ + + + diff --git a/plugins/messages/src/client/components/MessageFilterBar.vue b/plugins/messages/src/client/components/MessageFilterBar.vue new file mode 100644 index 00000000..f16ec638 --- /dev/null +++ b/plugins/messages/src/client/components/MessageFilterBar.vue @@ -0,0 +1,57 @@ + + + diff --git a/plugins/messages/src/client/components/MessageItem.vue b/plugins/messages/src/client/components/MessageItem.vue index 9cdbd047..2ddfd497 100644 --- a/plugins/messages/src/client/components/MessageItem.vue +++ b/plugins/messages/src/client/components/MessageItem.vue @@ -1,8 +1,9 @@ + + diff --git a/plugins/messages/src/client/components/MessageTag.vue b/plugins/messages/src/client/components/MessageTag.vue new file mode 100644 index 00000000..e5511387 --- /dev/null +++ b/plugins/messages/src/client/components/MessageTag.vue @@ -0,0 +1,23 @@ + + + diff --git a/plugins/messages/src/client/components/MessageToolbarActions.vue b/plugins/messages/src/client/components/MessageToolbarActions.vue new file mode 100644 index 00000000..7ece7281 --- /dev/null +++ b/plugins/messages/src/client/components/MessageToolbarActions.vue @@ -0,0 +1,60 @@ + + + diff --git a/plugins/messages/src/client/components/MessagesView.stories.ts b/plugins/messages/src/client/components/MessagesView.stories.ts index c6d673d0..bbdf46db 100644 --- a/plugins/messages/src/client/components/MessagesView.stories.ts +++ b/plugins/messages/src/client/components/MessagesView.stories.ts @@ -1,22 +1,29 @@ +import type { DevframeMessageEntry } from '@devframes/hub/types' import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { useMessageFilters } from '../composables/useMessageFilters' import { makeSampleEntries } from './_fixtures' import MessagesView from './MessagesView.vue' +// The view is driven by the shared `filters` façade (owned by `App.vue` in the +// real app); each story seeds one from a fixed entry list. +interface StoryArgs { + entries: DevframeMessageEntry[] + canOpenFile?: boolean +} + const meta = { title: 'Messages/MessagesView', component: MessagesView, tags: ['autodocs'], - argTypes: { - onDismiss: { action: 'dismissed' }, - onDismissMany: { action: 'dismissed many' }, - onClear: { action: 'cleared' }, - onOpenFile: { action: 'opened file' }, - onPersist: { action: 'persisted' }, - }, - decorators: [ - () => ({ template: '
' }), - ], -} satisfies Meta + render: (args: StoryArgs) => ({ + components: { MessagesView }, + setup() { + const filters = useMessageFilters(() => args.entries) + return { filters, args } + }, + template: '
', + }), +} satisfies Meta export default meta type Story = StoryObj diff --git a/plugins/messages/src/client/components/MessagesView.vue b/plugins/messages/src/client/components/MessagesView.vue index fd4fb589..49500cd6 100644 --- a/plugins/messages/src/client/components/MessagesView.vue +++ b/plugins/messages/src/client/components/MessagesView.vue @@ -1,15 +1,15 @@ diff --git a/plugins/messages/src/client/components/_fixtures.ts b/plugins/messages/src/client/components/_fixtures.ts index 96799490..0f3e92ec 100644 --- a/plugins/messages/src/client/components/_fixtures.ts +++ b/plugins/messages/src/client/components/_fixtures.ts @@ -1,4 +1,4 @@ -import type { DevframeMessageEntry } from '../../types' +import type { DevframeMessageEntry } from '@devframes/hub/types' /** Shared story fixtures — a feed that exercises every entry facet. */ export function makeSampleEntries(now: number = Date.now()): DevframeMessageEntry[] { diff --git a/plugins/messages/src/client/components/message-styles.ts b/plugins/messages/src/client/components/message-styles.ts new file mode 100644 index 00000000..619650a7 --- /dev/null +++ b/plugins/messages/src/client/components/message-styles.ts @@ -0,0 +1,38 @@ +import type { DevframeMessageEntryFrom, DevframeMessageLevel } from '@devframes/hub/types' + +// @unocss-include + +export interface LevelStyle { + icon: string + color: string + bg: string + label: string +} + +export interface FromStyle { + icon: string + color: string + label: string +} + +export const levels: Record = { + info: { icon: 'i-ph:info-duotone', color: 'dark:text-blue-200 text-blue-600', bg: 'bg-blue', label: 'Info' }, + warn: { icon: 'i-ph:warning-duotone', color: 'dark:text-amber-200 text-amber-600', bg: 'bg-amber', label: 'Warning' }, + error: { icon: 'i-ph:x-circle-duotone', color: 'dark:text-red-200 text-red-600', bg: 'bg-red', label: 'Error' }, + success: { icon: 'i-ph:check-circle-duotone', color: 'dark:text-green-200 text-green-600', bg: 'bg-green', label: 'Success' }, + debug: { icon: 'i-ph:bug-duotone', color: 'dark:text-violet-200 text-violet-600', bg: 'bg-violet', label: 'Debug' }, +} + +export const fromEntries: Record = { + server: { icon: 'i-ph:hexagon-duotone', color: 'text-green-600 dark:text-green-200', label: 'Server' }, + browser: { icon: 'i-ph:globe-simple-duotone', color: 'text-amber-600 dark:text-amber-200', label: 'Browser' }, +} + +/** Ordering used by the "By severity" sort mode. */ +export const levelPriority: Record = { + error: 0, + warn: 1, + info: 2, + success: 3, + debug: 4, +} diff --git a/plugins/messages/src/client/composables/useMessageFilters.ts b/plugins/messages/src/client/composables/useMessageFilters.ts new file mode 100644 index 00000000..f2a9bdd3 --- /dev/null +++ b/plugins/messages/src/client/composables/useMessageFilters.ts @@ -0,0 +1,174 @@ +import type { DevframeMessageEntry, DevframeMessageEntryFrom, DevframeMessageLevel } from '@devframes/hub/types' +import type { MaybeRefOrGetter } from 'vue' +import { computed, reactive, ref, toValue } from 'vue' +import { fromEntries, levelPriority, levels } from '../components/message-styles' + +export type SortMode = 'newest' | 'oldest' | 'level' + +const SORT_MODES: SortMode[] = ['newest', 'oldest', 'level'] + +const SORT_LABELS: Record = { + newest: 'Newest first', + oldest: 'Oldest first', + level: 'By severity', +} + +const SORT_ICONS: Record = { + newest: 'i-ph:sort-descending-duotone', + oldest: 'i-ph:sort-ascending-duotone', + level: 'i-ph:warning-diamond-duotone', +} + +/** + * The reactive search / sort / filter state over a message feed, plus the + * derived `filteredEntries`. Instantiated once in the smart wrapper (`App.vue`) + * so the nav bar's search + actions and the body's filter bar share a single + * source of truth. Returned as a `reactive` façade so its refs auto-unwrap in + * templates and can be threaded through as one prop. + */ +export interface MessageFilters { + search: string + sortBy: SortMode + /** Human label for the active sort mode (tooltip). */ + readonly sortLabel: string + /** Phosphor icon class for the active sort mode. */ + readonly sortIcon: string + cycleSortMode: () => void + + activeLevels: Set + activeSources: Set + activeCategories: Set + activeLabels: Set + toggleLevel: (level: string) => void + toggleSource: (from: DevframeMessageEntryFrom) => void + toggleCategory: (category: string) => void + toggleLabel: (label: string) => void + + readonly allLevels: DevframeMessageLevel[] + readonly allSources: DevframeMessageEntryFrom[] + readonly allCategories: string[] + readonly allLabels: string[] + + readonly hasActiveFilter: boolean + resetFilters: () => void + + /** Every entry, unfiltered (for resolving a still-selected entry). */ + readonly allEntries: DevframeMessageEntry[] + /** Entries matching the current filters, in the active sort order. */ + readonly filteredEntries: DevframeMessageEntry[] + readonly totalCount: number + readonly filteredCount: number +} + +export function useMessageFilters(source: MaybeRefOrGetter): MessageFilters { + const search = ref('') + const sortBy = ref('newest') + const activeLevels = ref(new Set()) + const activeSources = ref(new Set()) + const activeCategories = ref(new Set()) + const activeLabels = ref(new Set()) + + const allEntries = computed(() => toValue(source)) + + function cycleSortMode(): void { + const idx = SORT_MODES.indexOf(sortBy.value) + sortBy.value = SORT_MODES[(idx + 1) % SORT_MODES.length]! + } + + function toggle(set: Set, value: T): void { + if (set.has(value)) + set.delete(value) + else + set.add(value) + } + + const allLabels = computed(() => { + const labels = new Set() + for (const entry of allEntries.value) { + for (const label of entry.labels ?? []) + labels.add(label) + } + return Array.from(labels).sort() + }) + + const allCategories = computed(() => { + const cats = new Set() + for (const entry of allEntries.value) { + if (entry.category) + cats.add(entry.category) + } + return Array.from(cats).sort() + }) + + const hasActiveFilter = computed(() => + activeLevels.value.size > 0 + || activeSources.value.size > 0 + || activeCategories.value.size > 0 + || activeLabels.value.size > 0 + || search.value.length > 0) + + function resetFilters(): void { + activeLevels.value.clear() + activeSources.value.clear() + activeCategories.value.clear() + activeLabels.value.clear() + search.value = '' + } + + const filteredEntries = computed(() => { + let entries = allEntries.value + if (activeLevels.value.size > 0) + entries = entries.filter(e => activeLevels.value.has(e.level)) + if (activeSources.value.size > 0) + entries = entries.filter(e => activeSources.value.has(e.from as DevframeMessageEntryFrom)) + if (activeCategories.value.size > 0) + entries = entries.filter(e => e.category != null && activeCategories.value.has(e.category)) + if (activeLabels.value.size > 0) + entries = entries.filter(e => e.labels?.some(l => activeLabels.value.has(l))) + if (search.value) { + const q = search.value.toLowerCase() + entries = entries.filter(e => + e.message.toLowerCase().includes(q) + || e.description?.toLowerCase().includes(q) + || e.from?.toLowerCase().includes(q) + || e.category?.toLowerCase().includes(q) + || e.labels?.some(l => l.toLowerCase().includes(q))) + } + + if (sortBy.value === 'oldest') + return [...entries] + if (sortBy.value === 'level') + return entries.toSorted((a, b) => levelPriority[a.level] - levelPriority[b.level]) + return entries.toReversed() + }) + + return reactive({ + search, + sortBy, + sortLabel: computed(() => SORT_LABELS[sortBy.value]), + sortIcon: computed(() => SORT_ICONS[sortBy.value]), + cycleSortMode, + + activeLevels, + activeSources, + activeCategories, + activeLabels, + toggleLevel: (level: string) => toggle(activeLevels.value, level as DevframeMessageLevel), + toggleSource: (from: DevframeMessageEntryFrom) => toggle(activeSources.value, from), + toggleCategory: (category: string) => toggle(activeCategories.value, category), + toggleLabel: (label: string) => toggle(activeLabels.value, label), + + allLevels: Object.keys(levels) as DevframeMessageLevel[], + allSources: Object.keys(fromEntries) as DevframeMessageEntryFrom[], + allCategories, + allLabels, + + hasActiveFilter, + resetFilters, + + allEntries, + filteredEntries, + totalCount: computed(() => allEntries.value.length), + filteredCount: computed(() => filteredEntries.value.length), + }) as MessageFilters +} diff --git a/plugins/messages/src/client/index.ts b/plugins/messages/src/client/index.ts index 3942ca0b..068dc30d 100644 --- a/plugins/messages/src/client/index.ts +++ b/plugins/messages/src/client/index.ts @@ -4,6 +4,7 @@ import { createApp } from 'vue' import App from './App.vue' import 'virtual:uno.css' +import 'floating-vue/dist/style.css' import '@antfu/design/styles.css' import './style.css' @@ -51,12 +52,12 @@ export async function mountMessages( } } +export { useMessages } from './state/messages' +export type { MessagesState } from './state/messages' export type { DevframeMessageEntry, DevframeMessageEntryFrom, DevframeMessageEntryInput, DevframeMessageLevel, DevframeMessagesListDelta, -} from '../types' -export { useMessages } from './state/messages' -export type { MessagesState } from './state/messages' +} from '@devframes/hub/types' diff --git a/plugins/messages/src/client/state/messages.ts b/plugins/messages/src/client/state/messages.ts index 968dbf15..990ea78c 100644 --- a/plugins/messages/src/client/state/messages.ts +++ b/plugins/messages/src/client/state/messages.ts @@ -1,6 +1,6 @@ +import type { DevframeMessageEntry, DevframeMessagesListDelta } from '@devframes/hub/types' import type { DevframeRpcClient } from 'devframe/client' import type { Reactive } from 'vue' -import type { DevframeMessageEntry, DevframeMessagesListDelta } from '../../types' import { reactive } from 'vue' import { MESSAGES_UPDATED_EVENT } from '../../constants' diff --git a/plugins/messages/src/index.ts b/plugins/messages/src/index.ts index ca8a5768..981730ed 100644 --- a/plugins/messages/src/index.ts +++ b/plugins/messages/src/index.ts @@ -79,10 +79,12 @@ const messagesDevframe: DevframeDefinition = createMessagesDevframe() export default messagesDevframe export { DEFAULT_PORT, MESSAGES_UPDATED_EVENT, PLUGIN_ID } from './constants' +// The plugin's data vocabulary is the hub's — re-exported so the SPA, the +// embeddable client, and consumers can type against the plugin package alone. export type { DevframeMessageEntry, DevframeMessageEntryFrom, DevframeMessageEntryInput, DevframeMessageLevel, DevframeMessagesListDelta, -} from './types' +} from '@devframes/hub/types' diff --git a/plugins/messages/src/rpc/functions/add.ts b/plugins/messages/src/rpc/functions/add.ts index aa9ffcd1..10dbe6cb 100644 --- a/plugins/messages/src/rpc/functions/add.ts +++ b/plugins/messages/src/rpc/functions/add.ts @@ -1,4 +1,4 @@ -import type { DevframeMessageEntry, DevframeMessageEntryInput } from '../../types' +import type { DevframeMessageEntry, DevframeMessageEntryInput } from '@devframes/hub/types' import { defineMessagesRpc, getMessagesHost } from './_define' /** diff --git a/plugins/messages/src/rpc/functions/list.ts b/plugins/messages/src/rpc/functions/list.ts index bd21e396..28c02e7a 100644 --- a/plugins/messages/src/rpc/functions/list.ts +++ b/plugins/messages/src/rpc/functions/list.ts @@ -1,4 +1,4 @@ -import type { DevframeMessagesListDelta } from '../../types' +import type { DevframeMessagesListDelta } from '@devframes/hub/types' import { defineMessagesRpc, getMessagesHost } from './_define' /** diff --git a/plugins/messages/src/rpc/functions/update.ts b/plugins/messages/src/rpc/functions/update.ts index 467b1408..289e0c11 100644 --- a/plugins/messages/src/rpc/functions/update.ts +++ b/plugins/messages/src/rpc/functions/update.ts @@ -1,4 +1,4 @@ -import type { DevframeMessageEntry, DevframeMessageEntryInput } from '../../types' +import type { DevframeMessageEntry, DevframeMessageEntryInput } from '@devframes/hub/types' import { defineMessagesRpc, getMessagesHost } from './_define' /** diff --git a/plugins/messages/src/types.ts b/plugins/messages/src/types.ts deleted file mode 100644 index 64121b55..00000000 --- a/plugins/messages/src/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -// The plugin's data vocabulary is the hub's — re-exported so the SPA, the -// embeddable client, and consumers can type against the plugin package alone. -export type { - DevframeMessageEntry, - DevframeMessageEntryFrom, - DevframeMessageEntryInput, - DevframeMessageLevel, - DevframeMessagesListDelta, -} from '@devframes/hub/types' diff --git a/plugins/messages/tsdown.config.ts b/plugins/messages/tsdown.config.ts index 68f05709..f41c21c0 100644 --- a/plugins/messages/tsdown.config.ts +++ b/plugins/messages/tsdown.config.ts @@ -16,7 +16,6 @@ const serverEntries = { 'cli': 'src/cli.ts', 'vite': 'src/vite.ts', 'constants': 'src/constants.ts', - 'types': 'src/types.ts', 'node/index': 'src/node/index.ts', 'rpc/index': 'src/rpc/index.ts', } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 176daa13..bb898420 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1368,6 +1368,9 @@ importers: devframe: specifier: workspace:* version: link:../../packages/devframe + floating-vue: + specifier: catalog:frontend + version: 5.2.2(vue@3.5.40(typescript@6.0.3)) get-port-please: specifier: catalog:deps version: 3.2.0 diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-messages/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-messages/types.snapshot.d.ts deleted file mode 100644 index a412b924..00000000 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-messages/types.snapshot.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Generated by tsnapi — public API snapshot of `@devframes/plugin-messages/types` - */ -// #region Other -export { DevframeMessageEntry } -export { DevframeMessageEntryFrom } -export { DevframeMessageEntryInput } -export { DevframeMessageLevel } -export { DevframeMessagesListDelta } -// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-messages/types.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-messages/types.snapshot.js deleted file mode 100644 index c8f90a37..00000000 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-messages/types.snapshot.js +++ /dev/null @@ -1,4 +0,0 @@ -/** - * Generated by tsnapi — public API snapshot of `@devframes/plugin-messages/types` - */ -/* no exports */ \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json index 78f90a2c..1ea46c79 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -289,9 +289,6 @@ "@devframes/plugin-messages/constants": [ "./plugins/messages/src/constants.ts" ], - "@devframes/plugin-messages/types": [ - "./plugins/messages/src/types.ts" - ], "@devframes/plugin-messages/rpc": [ "./plugins/messages/src/rpc/index.ts" ],