mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +00:00
Compare commits
3 Commits
jaeone/err
...
jaewon/fe-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
277b8b2fa1 | ||
|
|
db8aee2f99 | ||
|
|
388c2de4cc |
BIN
.github/fe-1247-activity-tab.png
vendored
Normal file
BIN
.github/fe-1247-activity-tab.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 195 KiB |
@@ -93,6 +93,16 @@ const config: StorybookConfig = {
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/teamWorkspaceStore.ts'
|
||||
},
|
||||
{
|
||||
find: '@/composables/auth/useCurrentUser',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useCurrentUser.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/composables/useWorkspaceUI',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useWorkspaceUI.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/formatUtil',
|
||||
replacement:
|
||||
|
||||
22
src/components/ui/hover-card/HoverCard.vue
Normal file
22
src/components/ui/hover-card/HoverCard.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { HoverCardRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { HoverCardRootEmits, HoverCardRootProps } from 'reka-ui'
|
||||
import { provide, ref } from 'vue'
|
||||
|
||||
import { hoverCardOpenKey } from './hoverCardContext'
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
|
||||
const props = defineProps<HoverCardRootProps>()
|
||||
const emits = defineEmits<HoverCardRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
|
||||
const isOpen = ref(false)
|
||||
provide(hoverCardOpenKey, isOpen)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCardRoot v-bind="forwarded" v-model:open="isOpen">
|
||||
<slot />
|
||||
</HoverCardRoot>
|
||||
</template>
|
||||
51
src/components/ui/hover-card/HoverCardContent.vue
Normal file
51
src/components/ui/hover-card/HoverCardContent.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import { HoverCardContent, HoverCardPortal, useForwardProps } from 'reka-ui'
|
||||
import type { HoverCardContentProps } from 'reka-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { hoverCardOpenKey } from './hoverCardContext'
|
||||
|
||||
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
|
||||
const MODAL_BASE_Z_INDEX = 1700
|
||||
|
||||
const {
|
||||
class: className,
|
||||
side = 'bottom',
|
||||
sideOffset = 8,
|
||||
...rest
|
||||
} = defineProps<HoverCardContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const forwarded = useForwardProps(computed(() => rest))
|
||||
|
||||
// Body-portaled content sits at a static z-1700 unless a dialog that joined
|
||||
// @primeuix's 'modal' counter is open above it; then lift past that dialog.
|
||||
const open = inject(hoverCardOpenKey, undefined)
|
||||
const contentStyle = computed(() => {
|
||||
if (!open?.value) return undefined
|
||||
const topZIndex = ZIndex.getCurrent('modal')
|
||||
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent
|
||||
v-bind="forwarded"
|
||||
:side
|
||||
:side-offset
|
||||
:style="contentStyle"
|
||||
:class="
|
||||
cn(
|
||||
'z-1700 rounded-lg border border-border-subtle bg-secondary-background p-2.5 shadow-md outline-none',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</template>
|
||||
12
src/components/ui/hover-card/HoverCardTrigger.vue
Normal file
12
src/components/ui/hover-card/HoverCardTrigger.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { HoverCardTrigger } from 'reka-ui'
|
||||
import type { HoverCardTriggerProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<HoverCardTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCardTrigger v-bind="props">
|
||||
<slot />
|
||||
</HoverCardTrigger>
|
||||
</template>
|
||||
7
src/components/ui/hover-card/hoverCardContext.ts
Normal file
7
src/components/ui/hover-card/hoverCardContext.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
// Shares the root open-state with the content so it can lift its z-index above
|
||||
// a dialog that joined @primeuix's incrementing 'modal' counter (otherwise the
|
||||
// body-portaled content renders behind the settings dialog).
|
||||
export const hoverCardOpenKey: InjectionKey<Ref<boolean>> =
|
||||
Symbol('hoverCardOpen')
|
||||
72
src/components/ui/pagination/Pagination.vue
Normal file
72
src/components/ui/pagination/Pagination.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<PaginationRoot
|
||||
:page="page"
|
||||
:total="total"
|
||||
:items-per-page="itemsPerPage"
|
||||
:sibling-count="1"
|
||||
show-edges
|
||||
@update:page="(p: number) => emit('update:page', p)"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<PaginationPrev as-child>
|
||||
<Button variant="muted-textonly" size="md" class="text-sm">
|
||||
<i class="icon-[lucide--chevron-left] size-4" />
|
||||
{{ $t('g.previous') }}
|
||||
</Button>
|
||||
</PaginationPrev>
|
||||
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<PaginationListItem
|
||||
v-if="item.type === 'page'"
|
||||
:value="item.value"
|
||||
as-child
|
||||
>
|
||||
<Button
|
||||
:variant="item.value === page ? 'secondary' : 'muted-textonly'"
|
||||
size="icon"
|
||||
>
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
<PaginationEllipsis v-else :index="index" :class="ellipsisClass">
|
||||
…
|
||||
</PaginationEllipsis>
|
||||
</template>
|
||||
</PaginationList>
|
||||
<PaginationNext as-child>
|
||||
<Button variant="muted-textonly" size="md" class="text-sm">
|
||||
{{ $t('g.next') }}
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
</Button>
|
||||
</PaginationNext>
|
||||
</div>
|
||||
</PaginationRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PaginationEllipsis,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationNext,
|
||||
PaginationPrev,
|
||||
PaginationRoot
|
||||
} from 'reka-ui'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
total,
|
||||
itemsPerPage = 10
|
||||
} = defineProps<{
|
||||
page?: number
|
||||
total: number
|
||||
itemsPerPage?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:page': [page: number] }>()
|
||||
|
||||
const ellipsisClass =
|
||||
'inline-flex size-8 items-center justify-center text-sm text-muted-foreground'
|
||||
</script>
|
||||
17
src/components/ui/table/Table.vue
Normal file
17
src/components/ui/table/Table.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div :class="cn('relative w-full overflow-auto', className)">
|
||||
<table
|
||||
class="w-full caption-bottom border-separate border-spacing-0 text-sm"
|
||||
>
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
13
src/components/ui/table/TableBody.vue
Normal file
13
src/components/ui/table/TableBody.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<tbody :class="cn('[&_tr:last-child]:border-0', className)">
|
||||
<slot />
|
||||
</tbody>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
13
src/components/ui/table/TableCell.vue
Normal file
13
src/components/ui/table/TableCell.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<td :class="cn('px-2 py-2.5 align-middle whitespace-nowrap', className)">
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
21
src/components/ui/table/TableHead.vue
Normal file
21
src/components/ui/table/TableHead.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<th
|
||||
scope="col"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 px-2 text-left align-middle text-sm font-normal whitespace-nowrap text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</th>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
15
src/components/ui/table/TableHeader.vue
Normal file
15
src/components/ui/table/TableHeader.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<thead
|
||||
:class="cn('[&_tr]:border-b [&_tr]:border-interface-stroke/60', className)"
|
||||
>
|
||||
<slot />
|
||||
</thead>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
20
src/components/ui/table/TableRow.vue
Normal file
20
src/components/ui/table/TableRow.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<tr
|
||||
:class="
|
||||
cn(
|
||||
'border-b border-interface-stroke/60 transition-colors hover:bg-secondary-background/50 data-[state=selected]:bg-secondary-background/50',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
@@ -1559,6 +1559,7 @@
|
||||
"3DViewer": "3DViewer",
|
||||
"Canvas Navigation": "Canvas Navigation",
|
||||
"PlanCredits": "Plan & Credits",
|
||||
"Members": "Members",
|
||||
"Vue Nodes": "Nodes 2.0",
|
||||
"VueNodes": "Nodes 2.0",
|
||||
"Nodes 2_0": "Nodes 2.0",
|
||||
@@ -2881,9 +2882,37 @@
|
||||
"updatePassword": "Update Password"
|
||||
},
|
||||
"workspacePanel": {
|
||||
"activity": {
|
||||
"columns": {
|
||||
"creditsUsed": "Credits",
|
||||
"date": "Date",
|
||||
"eventDetails": "Event details",
|
||||
"eventType": "Event type",
|
||||
"user": "User"
|
||||
},
|
||||
"empty": "No activity yet.",
|
||||
"eventType": {
|
||||
"cloudRun": "Cloud workflow run",
|
||||
"partnerNode": "Partner node usage"
|
||||
},
|
||||
"fullActivity": "Full activity",
|
||||
"hoverCard": {
|
||||
"lastActivity": "Last activity",
|
||||
"partnerNodeUsed": "Partner node used",
|
||||
"totalCreditsUsed": "Total credits used"
|
||||
},
|
||||
"perUserHint": "Looking for total usage per user?",
|
||||
"seeMembers": "See Members"
|
||||
},
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
"planCredits": {
|
||||
"tabs": {
|
||||
"activity": "Activity",
|
||||
"overview": "Credits"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"planCredits": "Plan & Credits",
|
||||
@@ -2893,6 +2922,12 @@
|
||||
"placeholder": "Dashboard workspace settings"
|
||||
},
|
||||
"members": {
|
||||
"activity": {
|
||||
"daysAgo": "{count} day ago | {count} days ago",
|
||||
"hoursAgo": "{n} hr ago",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{n} min ago"
|
||||
},
|
||||
"header": "Members",
|
||||
"membersCount": "{count} of {maxSeats} members",
|
||||
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
|
||||
|
||||
@@ -95,6 +95,7 @@ import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMess
|
||||
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
||||
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
|
||||
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
|
||||
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import type {
|
||||
@@ -135,6 +136,14 @@ const { fetchBalance } = useBillingContext()
|
||||
const navRef = ref<HTMLElement | null>(null)
|
||||
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
|
||||
|
||||
// Let a panel deep-link into a sibling panel (e.g. the Activity tab → Members).
|
||||
const { requestedPanelKey } = useSettingsNavigation()
|
||||
watch(requestedPanelKey, (key) => {
|
||||
if (!key) return
|
||||
activeCategoryKey.value = key
|
||||
requestedPanelKey.value = null
|
||||
})
|
||||
|
||||
const searchableNavItems = computed(() =>
|
||||
navGroups.value.flatMap((g) =>
|
||||
g.items.map((item) => ({
|
||||
|
||||
@@ -27,13 +27,14 @@ const CATEGORY_ICONS: Record<string, string> = {
|
||||
keybinding: 'icon-[lucide--keyboard]',
|
||||
LiteGraph: 'icon-[lucide--workflow]',
|
||||
'Mask Editor': 'icon-[lucide--pen-tool]',
|
||||
Members: 'icon-[lucide--users]',
|
||||
Other: 'icon-[lucide--ellipsis]',
|
||||
PlanCredits: 'icon-[lucide--credit-card]',
|
||||
PlanCredits: 'icon-[lucide--receipt-text]',
|
||||
secrets: 'icon-[lucide--key-round]',
|
||||
'server-config': 'icon-[lucide--server]',
|
||||
subscription: 'icon-[lucide--credit-card]',
|
||||
user: 'icon-[lucide--user]',
|
||||
workspace: 'icon-[lucide--building-2]'
|
||||
workspace: 'icon-[lucide--receipt-text]'
|
||||
}
|
||||
|
||||
interface SettingPanelItem {
|
||||
@@ -175,16 +176,30 @@ export function useSettingUI(
|
||||
)
|
||||
}
|
||||
|
||||
// Workspace panel: only available on cloud with team workspaces enabled
|
||||
const workspacePanel: SettingPanelItem = {
|
||||
// Workspace panels: only available on cloud with team workspaces enabled.
|
||||
// The old single "Workspace" panel is split into two sidebar entries; the
|
||||
// Plan & Credits entry keeps the 'workspace' key so existing deep links land.
|
||||
const planCreditsPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'workspace',
|
||||
label: 'Workspace',
|
||||
label: 'PlanCredits',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/settings/WorkspacePanelContent.vue')
|
||||
import('@/platform/workspace/components/dialogs/settings/PlanCreditsPanelContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
const membersPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'workspace-members',
|
||||
label: 'Members',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/settings/WorkspaceMembersPanelContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -245,7 +260,9 @@ export function useSettingUI(
|
||||
aboutPanel,
|
||||
creditsPanel,
|
||||
userPanel,
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
|
||||
...(shouldShowWorkspacePanel.value
|
||||
? [planCreditsPanel, membersPanel]
|
||||
: []),
|
||||
keybindingPanel,
|
||||
extensionPanel,
|
||||
...(isDesktop ? [serverConfigPanel] : []),
|
||||
@@ -295,8 +312,13 @@ export function useSettingUI(
|
||||
key: 'workspace',
|
||||
label: 'Workspace',
|
||||
children: [
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
|
||||
...(isLoggedIn.value &&
|
||||
...(shouldShowWorkspacePanel.value
|
||||
? [planCreditsPanel.node, membersPanel.node]
|
||||
: []),
|
||||
// The legacy per-account Credits panel is redundant once the workspace
|
||||
// Plan & Credits panel is present, which now owns the credit balance.
|
||||
...(!shouldShowWorkspacePanel.value &&
|
||||
isLoggedIn.value &&
|
||||
!(isCloud && window.__CONFIG__?.subscription_required)
|
||||
? [creditsPanel.node]
|
||||
: [])
|
||||
|
||||
13
src/platform/settings/composables/useSettingsNavigation.ts
Normal file
13
src/platform/settings/composables/useSettingsNavigation.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// A one-shot request to switch the open Settings dialog to another panel, so a
|
||||
// panel's content can deep-link into a sibling panel (e.g. Activity → Members).
|
||||
const requestedPanelKey = ref<string | null>(null)
|
||||
|
||||
export function useSettingsNavigation() {
|
||||
function navigateToPanel(key: string) {
|
||||
requestedPanelKey.value = key
|
||||
}
|
||||
|
||||
return { requestedPanelKey, navigateToPanel }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import PlanCreditsPanelContent from './PlanCreditsPanelContent.vue'
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () => reactive({ workspaceName: 'Acme Team' })
|
||||
}))
|
||||
|
||||
const stubs = {
|
||||
WorkspaceProfilePic: { template: '<div />' },
|
||||
SubscriptionPanelContentWorkspace: {
|
||||
template: '<div data-testid="credits-body" />'
|
||||
},
|
||||
WorkspaceActivityContent: {
|
||||
props: ['search'],
|
||||
template: '<div data-testid="activity-body">{{ search }}</div>'
|
||||
}
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
return render(PlanCreditsPanelContent, { global: { plugins: [i18n], stubs } })
|
||||
}
|
||||
|
||||
describe('PlanCreditsPanelContent', () => {
|
||||
it('shows Credits and Activity tabs with Credits active by default', () => {
|
||||
renderPanel()
|
||||
expect(screen.getByRole('button', { name: 'Credits' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Activity' })).toBeTruthy()
|
||||
expect(screen.getByTestId('credits-body')).toBeTruthy()
|
||||
expect(screen.queryByTestId('activity-body')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the search box only on the Activity tab', async () => {
|
||||
renderPanel()
|
||||
expect(screen.queryByPlaceholderText('Search')).toBeNull()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Activity' }))
|
||||
expect(screen.getByTestId('activity-body')).toBeTruthy()
|
||||
expect(screen.queryByTestId('credits-body')).toBeNull()
|
||||
expect(screen.getByPlaceholderText('Search')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('passes the search query to the Activity tab and clears it on tab change', async () => {
|
||||
renderPanel()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Activity' }))
|
||||
await userEvent.type(screen.getByPlaceholderText('Search'), 'flux')
|
||||
expect(screen.getByTestId('activity-body').textContent).toContain('flux')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Credits' }))
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Activity' }))
|
||||
expect(screen.getByTestId('activity-body').textContent).not.toContain(
|
||||
'flux'
|
||||
)
|
||||
expect(
|
||||
(screen.getByPlaceholderText('Search') as HTMLInputElement).value
|
||||
).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="@container flex size-full min-h-0 flex-col">
|
||||
<header class="mb-6 flex items-center gap-4">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 text-3xl!"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<h1 class="text-3xl font-semibold text-base-foreground">
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="mb-4 flex w-full flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:gap-9"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:variant="activeView === tab.key ? 'secondary' : 'muted-textonly'"
|
||||
size="lg"
|
||||
@click="setView(tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</Button>
|
||||
</div>
|
||||
<SearchInput
|
||||
v-if="activeView === 'activity'"
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search')"
|
||||
size="lg"
|
||||
class="w-full @2xl:w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SubscriptionPanelContentWorkspace v-if="activeView === 'overview'" />
|
||||
<WorkspaceActivityContent
|
||||
v-else
|
||||
:search="searchQuery"
|
||||
:events="activityEvents"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import WorkspaceActivityContent from '@/platform/workspace/components/dialogs/settings/WorkspaceActivityContent.vue'
|
||||
import { useWorkspaceActivitySource } from '@/platform/workspace/composables/useWorkspaceActivitySource'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
type View = 'overview' | 'activity'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { workspaceName } = storeToRefs(workspaceStore)
|
||||
|
||||
const { events: activityEvents } = useWorkspaceActivitySource()
|
||||
|
||||
// The Invoices tab (owner/admin only) is added by FE-1245, which owns the
|
||||
// next-invoice banner + Stripe portal link that fill it.
|
||||
const tabs = computed<{ key: View; label: string }[]>(() => [
|
||||
{ key: 'overview', label: t('workspacePanel.planCredits.tabs.overview') },
|
||||
{ key: 'activity', label: t('workspacePanel.planCredits.tabs.activity') }
|
||||
])
|
||||
|
||||
const activeView = ref<View>('overview')
|
||||
const searchQuery = ref('')
|
||||
|
||||
function setView(view: View) {
|
||||
activeView.value = view
|
||||
searchQuery.value = ''
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import { activityFixture } from '@/platform/workspace/fixtures/activityFixtures'
|
||||
|
||||
import WorkspaceActivityContent from './WorkspaceActivityContent.vue'
|
||||
|
||||
// The Activity tab renders inside a flex, height-constrained dialog panel and
|
||||
// auto-sizes its page to the available height; the decorator reproduces that
|
||||
// container (and the @container query context the footer/tabs rely on).
|
||||
const meta: Meta<typeof WorkspaceActivityContent> = {
|
||||
title: 'Platform/Workspace/WorkspaceActivityContent',
|
||||
component: WorkspaceActivityContent,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'fullscreen' },
|
||||
decorators: [
|
||||
() => ({
|
||||
template:
|
||||
'<div class="@container flex h-[520px] flex-col p-6"><story /></div>'
|
||||
})
|
||||
],
|
||||
args: { search: '' }
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Populated: Story = {
|
||||
args: { events: activityFixture }
|
||||
}
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { events: [] }
|
||||
}
|
||||
|
||||
export const Searched: Story = {
|
||||
args: { events: activityFixture, search: 'partner' }
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import type { ActivityEvent } from '@/platform/workspace/composables/useWorkspaceActivity'
|
||||
|
||||
import WorkspaceActivityContent from './WorkspaceActivityContent.vue'
|
||||
|
||||
const {
|
||||
mockCanManageSubscription,
|
||||
mockNavigateToPanel,
|
||||
mockRequestMembersSort
|
||||
} = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
return {
|
||||
mockCanManageSubscription: ref(true),
|
||||
mockNavigateToPanel: vi.fn(),
|
||||
mockRequestMembersSort: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: {
|
||||
get value() {
|
||||
return { canManageSubscription: mockCanManageSubscription.value }
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
userDisplayName: { value: 'Ada Lovelace' },
|
||||
userEmail: { value: 'ada@example.com' }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/composables/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({ navigateToPanel: mockNavigateToPanel })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
|
||||
requestMembersSort: mockRequestMembersSort
|
||||
}))
|
||||
|
||||
vi.mock('@/config/comfyApi', () => ({
|
||||
getComfyPlatformBaseUrl: () => 'https://platform.test'
|
||||
}))
|
||||
|
||||
class NoopResizeObserver implements ResizeObserver {
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
}
|
||||
|
||||
function renderContent(events: ActivityEvent[] = []) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
return render(WorkspaceActivityContent, {
|
||||
props: { search: '', events },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
const creditedRow: ActivityEvent = {
|
||||
id: 'inflow',
|
||||
date: new Date('2026-07-14T12:00:00Z'),
|
||||
userName: '',
|
||||
eventType: 'Auto-reload',
|
||||
detail: '—',
|
||||
credits: 20000,
|
||||
credited: true
|
||||
}
|
||||
|
||||
describe('WorkspaceActivityContent', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.ResizeObserver = NoopResizeObserver
|
||||
mockCanManageSubscription.value = true
|
||||
mockNavigateToPanel.mockClear()
|
||||
mockRequestMembersSort.mockClear()
|
||||
})
|
||||
|
||||
it('renders the empty state when there is no activity', () => {
|
||||
renderContent([])
|
||||
expect(screen.getByText('No activity yet.')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the per-user footer actions to a billing manager', () => {
|
||||
renderContent([])
|
||||
const link = screen.getByRole('link', { name: /full activity/i })
|
||||
expect(link.getAttribute('href')).toBe(
|
||||
'https://platform.test/profile/usage'
|
||||
)
|
||||
expect(screen.getByRole('button', { name: /see members/i })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides the per-user footer from members', () => {
|
||||
mockCanManageSubscription.value = false
|
||||
renderContent([])
|
||||
expect(screen.queryByRole('link', { name: /full activity/i })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /see members/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('deep-links to the Members panel pre-sorted by spend', async () => {
|
||||
renderContent([])
|
||||
await userEvent.click(screen.getByRole('button', { name: /see members/i }))
|
||||
expect(mockRequestMembersSort).toHaveBeenCalledWith('credits')
|
||||
expect(mockNavigateToPanel).toHaveBeenCalledWith('workspace-members')
|
||||
})
|
||||
|
||||
it('marks a credit inflow with a leading plus', () => {
|
||||
renderContent([creditedRow])
|
||||
expect(screen.getByText(/^\+20,000$/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div
|
||||
ref="tableContainer"
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-interface-stroke/60"
|
||||
>
|
||||
<Table class="min-h-0 flex-1 px-4">
|
||||
<TableHeader class="sticky top-0 z-10 bg-base-background">
|
||||
<TableRow
|
||||
class="hover:bg-transparent [&>th]:h-14 [&>th]:border-b [&>th]:border-interface-stroke/60"
|
||||
>
|
||||
<TableHead class="w-40" :aria-sort="ariaSort('date')">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('date')">
|
||||
{{ $t('workspacePanel.activity.columns.date') }}
|
||||
<i :class="sortIcon('date')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-56" :aria-sort="ariaSort('user')">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('user')">
|
||||
{{ $t('workspacePanel.activity.columns.user') }}
|
||||
<i :class="sortIcon('user')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead :aria-sort="ariaSort('eventType')">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('eventType')">
|
||||
{{ $t('workspacePanel.activity.columns.eventType') }}
|
||||
<i :class="sortIcon('eventType')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-32" :aria-sort="ariaSort('detail')">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('detail')">
|
||||
{{ $t('workspacePanel.activity.columns.eventDetails') }}
|
||||
<i :class="sortIcon('detail')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-40" :aria-sort="ariaSort('credits')">
|
||||
<button
|
||||
:class="cn(sortHeaderClass, 'ml-auto')"
|
||||
@click="toggleSort('credits')"
|
||||
>
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{ $t('workspacePanel.activity.columns.creditsUsed') }}
|
||||
<i :class="sortIcon('credits')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="event in pagedItems"
|
||||
:key="event.id"
|
||||
class="hover:bg-transparent [&:last-child>td]:border-b-0 [&>td]:border-b [&>td]:border-interface-stroke/20"
|
||||
>
|
||||
<TableCell class="text-sm text-muted-foreground tabular-nums">
|
||||
{{ formatDate(event.date) }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<HoverCard
|
||||
v-if="event.userName"
|
||||
:open-delay="150"
|
||||
:close-delay="0"
|
||||
>
|
||||
<HoverCardTrigger
|
||||
as="div"
|
||||
class="flex w-fit cursor-default items-center gap-3"
|
||||
>
|
||||
<span
|
||||
class="flex size-5 shrink-0 items-center justify-center rounded-full"
|
||||
:style="{ backgroundColor: userBadgeColor(event.userName) }"
|
||||
>
|
||||
<span class="text-2xs font-bold text-base-foreground">
|
||||
{{ event.userName.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="truncate text-sm text-base-foreground">
|
||||
{{ event.userName }}
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent class="w-64" align="start">
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="flex h-5 items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
$t(
|
||||
'workspacePanel.activity.hoverCard.totalCreditsUsed'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<i
|
||||
class="icon-[lucide--coins] size-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="text-sm text-base-foreground tabular-nums">
|
||||
{{
|
||||
summaryFor(
|
||||
event.userName
|
||||
).totalCredits.toLocaleString()
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex h-5 items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
$t('workspacePanel.activity.hoverCard.lastActivity')
|
||||
}}
|
||||
</span>
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ lastActivityLabel(event.userName) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<span v-else class="text-sm text-muted-foreground">—</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground">
|
||||
<HoverCard
|
||||
v-if="event.partnerNode"
|
||||
:open-delay="150"
|
||||
:close-delay="0"
|
||||
>
|
||||
<HoverCardTrigger as="span" class="cursor-default">
|
||||
{{ event.eventType }}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent class="w-72" align="start">
|
||||
<div class="flex h-5 items-center justify-between gap-4">
|
||||
<span
|
||||
class="text-sm whitespace-nowrap text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
$t('workspacePanel.activity.hoverCard.partnerNodeUsed')
|
||||
}}
|
||||
</span>
|
||||
<span class="truncate text-sm text-base-foreground">
|
||||
{{ event.partnerNode }}
|
||||
</span>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<template v-else>{{ event.eventType }}</template>
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground tabular-nums">
|
||||
{{ event.detail || '—' }}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
:class="
|
||||
cn(
|
||||
'text-right text-sm tabular-nums',
|
||||
event.credited ? 'text-credit' : 'text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ event.credited ? '+' : ''
|
||||
}}{{ event.credits.toLocaleString() }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="pagedItems.length === 0" class="hover:bg-transparent">
|
||||
<TableCell
|
||||
:colspan="5"
|
||||
class="py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.activity.empty') }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 @2xl:h-8 @2xl:flex-row @2xl:items-center">
|
||||
<div
|
||||
v-if="canViewTeamUsage"
|
||||
class="flex flex-wrap items-center gap-x-6 gap-y-2"
|
||||
>
|
||||
<a
|
||||
:href="fullActivityUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex cursor-pointer items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
{{ $t('workspacePanel.activity.fullActivity') }}
|
||||
</a>
|
||||
<div class="flex items-center gap-3">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.activity.perUserHint') }}
|
||||
</p>
|
||||
<button
|
||||
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-[inherit] text-sm text-base-foreground transition-colors hover:text-muted-foreground"
|
||||
@click="goToMembers"
|
||||
>
|
||||
{{ $t('workspacePanel.activity.seeMembers') }}
|
||||
<i class="icon-[lucide--arrow-right] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
v-model:page="page"
|
||||
:total="total"
|
||||
:items-per-page="itemsPerPage"
|
||||
class="@2xl:ml-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import HoverCard from '@/components/ui/hover-card/HoverCard.vue'
|
||||
import HoverCardContent from '@/components/ui/hover-card/HoverCardContent.vue'
|
||||
import HoverCardTrigger from '@/components/ui/hover-card/HoverCardTrigger.vue'
|
||||
import Pagination from '@/components/ui/pagination/Pagination.vue'
|
||||
import Table from '@/components/ui/table/Table.vue'
|
||||
import TableBody from '@/components/ui/table/TableBody.vue'
|
||||
import TableCell from '@/components/ui/table/TableCell.vue'
|
||||
import TableHead from '@/components/ui/table/TableHead.vue'
|
||||
import TableHeader from '@/components/ui/table/TableHeader.vue'
|
||||
import TableRow from '@/components/ui/table/TableRow.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
|
||||
import { useAutoPageSize } from '@/platform/workspace/composables/useAutoPageSize'
|
||||
import { requestMembersSort } from '@/platform/workspace/composables/useMembersPanel'
|
||||
import { useWorkspaceActivity } from '@/platform/workspace/composables/useWorkspaceActivity'
|
||||
import type {
|
||||
ActivityEvent,
|
||||
ActivitySortField
|
||||
} from '@/platform/workspace/composables/useWorkspaceActivity'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
|
||||
import { formatRelativeTime } from '@/platform/workspace/utils/relativeTime'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
// `events` is the data seam: the tab shell mounts this with no events (empty
|
||||
// state) until FE-1249 wires the per-workspace usage API and passes them in.
|
||||
const { search, events = [] } = defineProps<{
|
||||
search: string
|
||||
events?: ActivityEvent[]
|
||||
}>()
|
||||
|
||||
const { t, d } = useI18n()
|
||||
|
||||
const tableContainer = ref<HTMLElement | null>(null)
|
||||
const { pageSize } = useAutoPageSize(tableContainer, 1)
|
||||
|
||||
// Owners/admins see team-wide activity + the per-user footer; members see only
|
||||
// their own usage, scoped to their name.
|
||||
const { permissions } = useWorkspaceUI()
|
||||
const { userDisplayName, userEmail } = useCurrentUser()
|
||||
const canViewTeamUsage = computed(() => permissions.value.canManageSubscription)
|
||||
const selfName = computed(() =>
|
||||
canViewTeamUsage.value
|
||||
? null
|
||||
: userDisplayName.value || userEmail.value || t('g.you')
|
||||
)
|
||||
|
||||
const fullActivityUrl = `${getComfyPlatformBaseUrl()}/profile/usage`
|
||||
|
||||
const { navigateToPanel } = useSettingsNavigation()
|
||||
|
||||
function goToMembers() {
|
||||
requestMembersSort('credits')
|
||||
navigateToPanel('workspace-members')
|
||||
}
|
||||
|
||||
const {
|
||||
page,
|
||||
total,
|
||||
itemsPerPage,
|
||||
pagedItems,
|
||||
sortField,
|
||||
sortDirection,
|
||||
toggleSort,
|
||||
userSummaries
|
||||
} = useWorkspaceActivity(
|
||||
() => search,
|
||||
pageSize,
|
||||
selfName,
|
||||
() => events
|
||||
)
|
||||
|
||||
function summaryFor(userName: string) {
|
||||
return (
|
||||
userSummaries.value.get(userName) ?? {
|
||||
totalCredits: 0,
|
||||
lastActivity: new Date()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function lastActivityLabel(userName: string): string {
|
||||
return formatRelativeTime(summaryFor(userName).lastActivity, new Date(), {
|
||||
justNow: t('workspacePanel.members.activity.justNow'),
|
||||
minutesAgo: (n) => t('workspacePanel.members.activity.minutesAgo', { n }),
|
||||
hoursAgo: (n) => t('workspacePanel.members.activity.hoursAgo', { n }),
|
||||
daysAgo: (n) => t('workspacePanel.members.activity.daysAgo', n)
|
||||
})
|
||||
}
|
||||
|
||||
const sortHeaderClass =
|
||||
'flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left font-[inherit] text-sm text-muted-foreground'
|
||||
|
||||
function sortIcon(field: ActivitySortField) {
|
||||
if (sortField.value !== field) return 'icon-[lucide--chevrons-up-down] size-3'
|
||||
return sortDirection.value === 'asc'
|
||||
? 'icon-[lucide--chevron-up] size-3'
|
||||
: 'icon-[lucide--chevron-down] size-3'
|
||||
}
|
||||
|
||||
function ariaSort(
|
||||
field: ActivitySortField
|
||||
): 'ascending' | 'descending' | 'none' {
|
||||
if (sortField.value !== field) return 'none'
|
||||
return sortDirection.value === 'asc' ? 'ascending' : 'descending'
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return d(date, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
import WorkspaceMembersPanelContent from './WorkspaceMembersPanelContent.vue'
|
||||
|
||||
const { mockFetchMembers, mockFetchPendingInvites } = vi.hoisted(() => ({
|
||||
mockFetchMembers: vi.fn(),
|
||||
mockFetchPendingInvites: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
|
||||
useTeamWorkspaceStore: () =>
|
||||
reactive({
|
||||
workspaceName: 'Acme Team',
|
||||
fetchMembers: mockFetchMembers,
|
||||
fetchPendingInvites: mockFetchPendingInvites
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({ workspaceRole: ref('owner') })
|
||||
}))
|
||||
|
||||
const stubs = {
|
||||
WorkspaceProfilePic: { template: '<div />' },
|
||||
MembersPanelContent: { template: '<div data-testid="members-body" />' }
|
||||
}
|
||||
|
||||
describe('WorkspaceMembersPanelContent', () => {
|
||||
it('fetches members and pending invites on mount', () => {
|
||||
render(WorkspaceMembersPanelContent, { global: { stubs } })
|
||||
expect(mockFetchMembers).toHaveBeenCalled()
|
||||
expect(mockFetchPendingInvites).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="flex size-full flex-col">
|
||||
<header class="mb-6 flex items-center gap-4">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 text-3xl!"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<h1 class="text-3xl font-semibold text-base-foreground">
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
</header>
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { workspaceName } = storeToRefs(workspaceStore)
|
||||
const { fetchMembers, fetchPendingInvites } = workspaceStore
|
||||
const { workspaceRole } = useWorkspaceUI()
|
||||
|
||||
onMounted(() => {
|
||||
fetchMembers()
|
||||
fetchPendingInvites()
|
||||
})
|
||||
</script>
|
||||
@@ -1,129 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
|
||||
import WorkspacePanelContent from './WorkspacePanelContent.vue'
|
||||
|
||||
const mockFetchMembers = vi.fn()
|
||||
const mockFetchPendingInvites = vi.fn()
|
||||
|
||||
const { mockMembers, mockWorkspaceType } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
|
||||
return {
|
||||
mockMembers: ref<WorkspaceMember[]>([]),
|
||||
mockWorkspaceType: ref<'personal' | 'team'>('team')
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
storeToRefs: (store: Record<string, unknown>) => store
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
return {
|
||||
useTeamWorkspaceStore: () => ({
|
||||
workspaceName: ref('Acme Team'),
|
||||
members: mockMembers,
|
||||
fetchMembers: mockFetchMembers,
|
||||
fetchPendingInvites: mockFetchPendingInvites
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
return {
|
||||
useWorkspaceUI: () => ({
|
||||
workspaceType: mockWorkspaceType,
|
||||
workspaceRole: ref('owner')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue',
|
||||
() => ({
|
||||
default: { name: 'SubscriptionPanelContentWorkspace', template: '<div />' }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue',
|
||||
() => ({
|
||||
default: { name: 'MembersPanelContent', template: '<div />' }
|
||||
})
|
||||
)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function createMember(id: string): WorkspaceMember {
|
||||
return {
|
||||
id,
|
||||
name: `Member ${id}`,
|
||||
email: `member${id}@example.com`,
|
||||
joinDate: new Date('2025-01-15'),
|
||||
role: 'member',
|
||||
isOriginalOwner: false
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent() {
|
||||
return render(WorkspacePanelContent, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { WorkspaceProfilePic: true }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkspacePanelContent members tab label', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMembers.value = []
|
||||
mockWorkspaceType.value = 'team'
|
||||
})
|
||||
|
||||
it('shows the counted label for team workspaces with multiple members', () => {
|
||||
mockMembers.value = [createMember('1'), createMember('2')]
|
||||
renderComponent()
|
||||
expect(screen.getByText(/workspacePanel\.tabs\.membersCount/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('drops the count when the owner is the only member', () => {
|
||||
mockMembers.value = [createMember('1')]
|
||||
renderComponent()
|
||||
expect(screen.getByText('workspacePanel.members.header')).toBeTruthy()
|
||||
expect(screen.queryByText(/workspacePanel\.tabs\.membersCount/)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the plain Members label for personal workspaces', () => {
|
||||
mockWorkspaceType.value = 'personal'
|
||||
mockMembers.value = [createMember('1'), createMember('2')]
|
||||
renderComponent()
|
||||
expect(screen.getByText('workspacePanel.members.header')).toBeTruthy()
|
||||
expect(screen.queryByText(/workspacePanel\.tabs\.membersCount/)).toBeNull()
|
||||
})
|
||||
|
||||
it('fetches members and pending invites on mount', () => {
|
||||
renderComponent()
|
||||
expect(mockFetchMembers).toHaveBeenCalled()
|
||||
expect(mockFetchPendingInvites).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<div class="flex size-full flex-col">
|
||||
<header class="mb-6 flex items-center gap-4">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 text-3xl!"
|
||||
:workspace-name="workspaceName"
|
||||
/>
|
||||
<h1 class="text-3xl font-semibold text-base-foreground">
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
</header>
|
||||
<TabsRoot v-model="activeTab">
|
||||
<TabsList class="flex items-center gap-2 pb-1">
|
||||
<TabsTrigger
|
||||
value="plan"
|
||||
:class="
|
||||
cn(
|
||||
tabTriggerBase,
|
||||
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ $t('workspacePanel.tabs.planCredits') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="members"
|
||||
:class="
|
||||
cn(
|
||||
tabTriggerBase,
|
||||
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
showMembersTabCount
|
||||
? $t('workspacePanel.tabs.membersCount', {
|
||||
count: members.length
|
||||
})
|
||||
: $t('workspacePanel.members.header')
|
||||
}}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="plan" class="mt-4">
|
||||
<SubscriptionPanelContentWorkspace />
|
||||
</TabsContent>
|
||||
<TabsContent value="members" class="mt-4">
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const tabTriggerBase =
|
||||
'flex items-center justify-center shrink-0 px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200 outline-hidden border-none'
|
||||
const tabTriggerActive =
|
||||
'bg-interface-menu-component-surface-hovered text-text-primary font-bold'
|
||||
const tabTriggerInactive =
|
||||
'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
|
||||
|
||||
const { defaultTab = 'plan' } = defineProps<{
|
||||
defaultTab?: string
|
||||
}>()
|
||||
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { workspaceName, members } = storeToRefs(workspaceStore)
|
||||
const { fetchMembers, fetchPendingInvites } = workspaceStore
|
||||
|
||||
const { workspaceType, workspaceRole } = useWorkspaceUI()
|
||||
const isPersonalWorkspace = computed(() => workspaceType.value === 'personal')
|
||||
const activeTab = ref(defaultTab)
|
||||
|
||||
// Per design, the tab counts members only when there is more than the owner
|
||||
const showMembersTabCount = computed(
|
||||
() => !isPersonalWorkspace.value && members.value.length > 1
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
fetchMembers()
|
||||
fetchPendingInvites()
|
||||
})
|
||||
</script>
|
||||
151
src/platform/workspace/composables/useAutoPageSize.test.ts
Normal file
151
src/platform/workspace/composables/useAutoPageSize.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useAutoPageSize } from './useAutoPageSize'
|
||||
|
||||
const resizeObserverState = vi.hoisted(() => {
|
||||
const state = {
|
||||
callback: null as ResizeObserverCallback | null,
|
||||
observe: vi.fn<(element: Element) => void>(),
|
||||
disconnect: vi.fn<() => void>()
|
||||
}
|
||||
|
||||
const MockResizeObserver: typeof ResizeObserver = class MockResizeObserver implements ResizeObserver {
|
||||
observe = state.observe
|
||||
unobserve = vi.fn()
|
||||
disconnect = state.disconnect
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
state.callback = callback
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = MockResizeObserver
|
||||
|
||||
return state
|
||||
})
|
||||
|
||||
interface ContainerShape {
|
||||
clientHeight: number
|
||||
rowHeight: number | null
|
||||
headerHeight: number | null
|
||||
}
|
||||
|
||||
function fakeContainer({
|
||||
clientHeight,
|
||||
rowHeight,
|
||||
headerHeight
|
||||
}: ContainerShape): HTMLElement {
|
||||
return {
|
||||
clientHeight,
|
||||
querySelector(selector: string) {
|
||||
if (selector === 'tbody tr') {
|
||||
return rowHeight === null
|
||||
? null
|
||||
: { getBoundingClientRect: () => ({ height: rowHeight }) }
|
||||
}
|
||||
if (selector === 'thead') {
|
||||
return headerHeight === null
|
||||
? null
|
||||
: { getBoundingClientRect: () => ({ height: headerHeight }) }
|
||||
}
|
||||
if (selector === 'table') return {}
|
||||
return null
|
||||
}
|
||||
} as unknown as HTMLElement
|
||||
}
|
||||
|
||||
function runInScope(container: Ref<HTMLElement | null>, min?: number) {
|
||||
const scope = effectScope()
|
||||
const result = scope.run(() => useAutoPageSize(container, min))!
|
||||
return { ...result, stop: () => scope.stop() }
|
||||
}
|
||||
|
||||
describe('useAutoPageSize', () => {
|
||||
beforeEach(() => {
|
||||
resizeObserverState.callback = null
|
||||
resizeObserverState.observe.mockClear()
|
||||
resizeObserverState.disconnect.mockClear()
|
||||
})
|
||||
|
||||
it('fits as many whole rows as the container height allows', () => {
|
||||
const container = ref(
|
||||
fakeContainer({ clientHeight: 500, rowHeight: 41, headerHeight: 56 })
|
||||
)
|
||||
const { pageSize } = runInScope(container, 1)
|
||||
|
||||
resizeObserverState.callback!([], {} as ResizeObserver)
|
||||
|
||||
// (500 - 56) / 41 = 10.83 -> floored to 10 whole rows
|
||||
expect(pageSize.value).toBe(10)
|
||||
})
|
||||
|
||||
it('floors a fractional fit so a not-quite-fitting row is left out', () => {
|
||||
const container = ref(
|
||||
fakeContainer({ clientHeight: 449, rowHeight: 41, headerHeight: 0 })
|
||||
)
|
||||
const { pageSize } = runInScope(container, 1)
|
||||
|
||||
resizeObserverState.callback!([], {} as ResizeObserver)
|
||||
|
||||
// 449 / 41 = 10.95 -> 10, never 11 (which would overflow + show a scrollbar)
|
||||
expect(pageSize.value).toBe(10)
|
||||
})
|
||||
|
||||
it('never returns fewer than the requested minimum', () => {
|
||||
const container = ref(
|
||||
fakeContainer({ clientHeight: 50, rowHeight: 41, headerHeight: 0 })
|
||||
)
|
||||
const { pageSize } = runInScope(container, 5)
|
||||
|
||||
resizeObserverState.callback!([], {} as ResizeObserver)
|
||||
|
||||
// fit is 1, but min=5 wins
|
||||
expect(pageSize.value).toBe(5)
|
||||
})
|
||||
|
||||
it('falls back to a default row height before any rows are rendered', () => {
|
||||
const container = ref(
|
||||
fakeContainer({ clientHeight: 420, rowHeight: null, headerHeight: 0 })
|
||||
)
|
||||
const { pageSize } = runInScope(container, 1)
|
||||
|
||||
resizeObserverState.callback!([], {} as ResizeObserver)
|
||||
|
||||
// no tbody row yet -> FALLBACK_ROW_HEIGHT 41: floor(420 / 41) = 10
|
||||
expect(pageSize.value).toBe(10)
|
||||
})
|
||||
|
||||
it('re-measures when the table height changes after rows load', () => {
|
||||
const container = ref(
|
||||
fakeContainer({ clientHeight: 420, rowHeight: null, headerHeight: 0 })
|
||||
)
|
||||
const { pageSize } = runInScope(container, 1)
|
||||
|
||||
resizeObserverState.callback!([], {} as ResizeObserver)
|
||||
expect(pageSize.value).toBe(10)
|
||||
|
||||
// real rows arrive taller than the fallback; the observer fires again
|
||||
container.value = fakeContainer({
|
||||
clientHeight: 420,
|
||||
rowHeight: 60,
|
||||
headerHeight: 0
|
||||
})
|
||||
resizeObserverState.callback!([], {} as ResizeObserver)
|
||||
|
||||
// floor(420 / 60) = 7
|
||||
expect(pageSize.value).toBe(7)
|
||||
})
|
||||
|
||||
it('disconnects the observer when the scope stops', () => {
|
||||
const container = ref(
|
||||
fakeContainer({ clientHeight: 500, rowHeight: 41, headerHeight: 0 })
|
||||
)
|
||||
const { stop } = runInScope(container, 1)
|
||||
|
||||
stop()
|
||||
|
||||
expect(resizeObserverState.disconnect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
55
src/platform/workspace/composables/useAutoPageSize.ts
Normal file
55
src/platform/workspace/composables/useAutoPageSize.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { onScopeDispose, ref, watch } from 'vue'
|
||||
|
||||
const FALLBACK_ROW_HEIGHT = 41
|
||||
const MIN_ROWS = 5
|
||||
|
||||
/**
|
||||
* Derive a table's rows-per-page from the live height of its scroll container so
|
||||
* a taller dialog shows more rows instead of leaving empty space. Row and header
|
||||
* heights are read from the rendered table, so it adapts if the design changes.
|
||||
*/
|
||||
export function useAutoPageSize(
|
||||
containerRef: Ref<HTMLElement | null>,
|
||||
min: number = MIN_ROWS
|
||||
) {
|
||||
const pageSize = ref(min)
|
||||
|
||||
function measure() {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
// Use fractional (getBoundingClientRect) heights, not the integer
|
||||
// offsetHeight: truncating each row's height makes the floor below think an
|
||||
// extra partial row fits, which overflows the container by a few pixels and
|
||||
// shows a scrollbar. Fractional heights keep a not-quite-fitting row out.
|
||||
const rowHeight =
|
||||
container.querySelector<HTMLElement>('tbody tr')?.getBoundingClientRect()
|
||||
.height || FALLBACK_ROW_HEIGHT
|
||||
const headerHeight =
|
||||
container.querySelector<HTMLElement>('thead')?.getBoundingClientRect()
|
||||
.height ?? 0
|
||||
const fit = Math.floor((container.clientHeight - headerHeight) / rowHeight)
|
||||
pageSize.value = Math.max(min, fit)
|
||||
}
|
||||
|
||||
let observer: ResizeObserver | null = null
|
||||
watch(
|
||||
containerRef,
|
||||
(el) => {
|
||||
observer?.disconnect()
|
||||
if (!el) return
|
||||
observer = new ResizeObserver(() => measure())
|
||||
observer.observe(el)
|
||||
// Also observe the table itself: async-loaded rows change the table's
|
||||
// height without resizing the container, so the initial measure (taken
|
||||
// against an empty state or fallback row height) would otherwise stick.
|
||||
// Re-measuring converges — pageSize stabilizes once real rows exist.
|
||||
const table = el.querySelector('table')
|
||||
if (table) observer.observe(table)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
onScopeDispose(() => observer?.disconnect())
|
||||
|
||||
return { pageSize }
|
||||
}
|
||||
@@ -23,6 +23,17 @@ type ActiveView = 'active' | 'pending'
|
||||
type SortField = 'inviteDate' | 'expiryDate' | 'role'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
// One-shot cross-panel request from the Activity tab to open the Members table
|
||||
// sorted by a per-member column. Those columns ship with member auditing on the
|
||||
// Members panel (DES-479); until then useMembersPanel clears the request without
|
||||
// a matching column to apply it to.
|
||||
type RequestedMembersSort = 'credits' | 'lastActivity'
|
||||
const requestedMembersSort = ref<RequestedMembersSort | null>(null)
|
||||
|
||||
export function requestMembersSort(field: RequestedMembersSort) {
|
||||
requestedMembersSort.value = field
|
||||
}
|
||||
|
||||
export function sortMembers(
|
||||
members: WorkspaceMember[],
|
||||
currentUserEmail: string | null,
|
||||
@@ -113,6 +124,9 @@ export function useMembersPanel() {
|
||||
const { isOnTeamPlan, isCancelled, hasLapsedTeamPlan } = useTeamPlan()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
|
||||
// Consume the Activity tab's cross-panel sort request (see requestMembersSort).
|
||||
if (requestedMembersSort.value) requestedMembersSort.value = null
|
||||
|
||||
// The team plan caps members at a flat MAX_WORKSPACE_MEMBERS, independent of
|
||||
// the subscription tier.
|
||||
const maxSeats = computed(() => MAX_WORKSPACE_MEMBERS)
|
||||
|
||||
102
src/platform/workspace/composables/useWorkspaceActivity.test.ts
Normal file
102
src/platform/workspace/composables/useWorkspaceActivity.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { activityFixture } from '@/platform/workspace/fixtures/activityFixtures'
|
||||
|
||||
import { useWorkspaceActivity } from './useWorkspaceActivity'
|
||||
import type { ActivityEvent } from './useWorkspaceActivity'
|
||||
|
||||
interface SetupOptions {
|
||||
search?: Ref<string>
|
||||
pageSize?: Ref<number>
|
||||
selfName?: Ref<string | null>
|
||||
source?: ActivityEvent[]
|
||||
}
|
||||
|
||||
function setup(options: SetupOptions = {}) {
|
||||
const search = options.search ?? ref('')
|
||||
const pageSize = options.pageSize ?? ref(100)
|
||||
const selfName = options.selfName ?? ref<string | null>(null)
|
||||
const scope = effectScope()
|
||||
const api = scope.run(() =>
|
||||
useWorkspaceActivity(
|
||||
search,
|
||||
pageSize,
|
||||
selfName,
|
||||
options.source ?? activityFixture
|
||||
)
|
||||
)!
|
||||
return { ...api, search, pageSize, selfName, stop: () => scope.stop() }
|
||||
}
|
||||
|
||||
describe('useWorkspaceActivity', () => {
|
||||
it('shows the whole ledger to an owner (no self scope)', () => {
|
||||
const { total } = setup()
|
||||
expect(total.value).toBe(activityFixture.length)
|
||||
})
|
||||
|
||||
it('scopes a member to their own usage rows plus workspace credit inflows', () => {
|
||||
const { total, pagedItems } = setup({
|
||||
selfName: ref('Ada Lovelace')
|
||||
})
|
||||
// Ada's 4 usage rows + the 2 credited inflows (userName '')
|
||||
expect(total.value).toBe(6)
|
||||
const names = new Set(pagedItems.value.map((e) => e.userName))
|
||||
expect(names).toEqual(new Set(['Ada Lovelace', '']))
|
||||
})
|
||||
|
||||
it('searches user name and event type only, not other columns', () => {
|
||||
const search = ref('')
|
||||
const { total } = setup({ search })
|
||||
|
||||
search.value = 'ada'
|
||||
expect(total.value).toBe(4) // the 4 "Ada Lovelace" rows
|
||||
|
||||
search.value = 'partner'
|
||||
expect(total.value).toBe(5) // the 5 "Partner node usage" rows
|
||||
|
||||
search.value = '2 runs' // a detail value — not searched
|
||||
expect(total.value).toBe(0)
|
||||
})
|
||||
|
||||
it('sorts by date descending by default and dispatches other fields', () => {
|
||||
const { pagedItems, sortField, toggleSort } = setup()
|
||||
|
||||
expect(pagedItems.value[0].id).toBe('evt-01') // newest date
|
||||
|
||||
toggleSort('credits')
|
||||
expect(sortField.value).toBe('credits')
|
||||
expect(pagedItems.value[0].id).toBe('evt-12') // 50000, highest
|
||||
|
||||
toggleSort('credits') // flip to ascending
|
||||
expect(pagedItems.value[0].id).toBe('evt-09') // 760, lowest
|
||||
})
|
||||
|
||||
it('slices to the current page size', () => {
|
||||
const { pagedItems, total } = setup({ pageSize: ref(5) })
|
||||
expect(total.value).toBe(activityFixture.length)
|
||||
expect(pagedItems.value).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('clamps the page when the filtered result shrinks past it', async () => {
|
||||
const search = ref('')
|
||||
const { page, search: s } = setup({ search, pageSize: ref(5) })
|
||||
|
||||
page.value = 3 // valid: ceil(14 / 5) = 3 pages
|
||||
s.value = 'katherine' // narrows to 2 rows -> 1 page
|
||||
await nextTick()
|
||||
|
||||
expect(page.value).toBe(1)
|
||||
})
|
||||
|
||||
it('rolls up per-user totals excluding credit inflows', () => {
|
||||
const { userSummaries } = setup()
|
||||
const ada = userSummaries.value.get('Ada Lovelace')
|
||||
|
||||
expect(ada?.totalCredits).toBe(1840 + 5120 + 4100 + 2950)
|
||||
expect(ada?.lastActivity).toEqual(new Date('2026-07-14T09:32:00Z'))
|
||||
// credited inflows (userName '') never appear in the rollups
|
||||
expect(userSummaries.value.has('')).toBe(false)
|
||||
})
|
||||
})
|
||||
133
src/platform/workspace/composables/useWorkspaceActivity.ts
Normal file
133
src/platform/workspace/composables/useWorkspaceActivity.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { computed, ref, toValue, watch } from 'vue'
|
||||
|
||||
export interface ActivityEvent {
|
||||
id: string
|
||||
date: Date
|
||||
userName: string
|
||||
eventType: string
|
||||
detail: string
|
||||
credits: number
|
||||
/** The partner node used, for 'Partner node usage' events. */
|
||||
partnerNode?: string
|
||||
/** True for credit inflows (auto-reload, top-up) vs. usage outflows. */
|
||||
credited?: boolean
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
totalCredits: number
|
||||
lastActivity: Date
|
||||
}
|
||||
|
||||
export type ActivitySortField =
|
||||
| 'date'
|
||||
| 'user'
|
||||
| 'eventType'
|
||||
| 'detail'
|
||||
| 'credits'
|
||||
|
||||
/**
|
||||
* Headless state for the workspace Activity ledger: role-scoped filtering,
|
||||
* search, sort, auto-paginated slicing, and per-user rollups.
|
||||
*
|
||||
* `source` is the data seam. It defaults to an empty list, so the ledger renders
|
||||
* its empty state until a caller supplies events; the per-workspace usage API
|
||||
* that will feed it is tracked in FE-1249. Swapping in real data means passing a
|
||||
* populated ref/getter here — every downstream computed stays untouched.
|
||||
*/
|
||||
export function useWorkspaceActivity(
|
||||
search: MaybeRefOrGetter<string>,
|
||||
pageSize: MaybeRefOrGetter<number>,
|
||||
selfName: MaybeRefOrGetter<string | null> = null,
|
||||
source: MaybeRefOrGetter<ActivityEvent[]> = []
|
||||
) {
|
||||
const page = ref(1)
|
||||
const perPage = computed(() => Math.max(1, toValue(pageSize)))
|
||||
const sortField = ref<ActivitySortField>('date')
|
||||
const sortDirection = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
// Members only see their own usage; credit inflows stay workspace-level.
|
||||
const base = computed<ActivityEvent[]>(() => {
|
||||
const events = toValue(source)
|
||||
const self = toValue(selfName)
|
||||
if (!self) return events
|
||||
return events.filter((event) => event.credited || event.userName === self)
|
||||
})
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = toValue(search).trim().toLowerCase()
|
||||
if (!q) return base.value
|
||||
return base.value.filter(
|
||||
(event) =>
|
||||
event.userName.toLowerCase().includes(q) ||
|
||||
event.eventType.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const sorted = computed(() => {
|
||||
const dir = sortDirection.value === 'asc' ? 1 : -1
|
||||
return [...filtered.value].sort((a, b) => {
|
||||
if (sortField.value === 'credits') return dir * (a.credits - b.credits)
|
||||
if (sortField.value === 'user')
|
||||
return dir * a.userName.localeCompare(b.userName)
|
||||
if (sortField.value === 'eventType')
|
||||
return dir * a.eventType.localeCompare(b.eventType)
|
||||
if (sortField.value === 'detail')
|
||||
return dir * a.detail.localeCompare(b.detail)
|
||||
return dir * (a.date.getTime() - b.date.getTime())
|
||||
})
|
||||
})
|
||||
|
||||
const total = computed(() => filtered.value.length)
|
||||
|
||||
const pagedItems = computed(() => {
|
||||
const start = (page.value - 1) * perPage.value
|
||||
return sorted.value.slice(start, start + perPage.value)
|
||||
})
|
||||
|
||||
function toggleSort(field: ActivitySortField) {
|
||||
if (sortField.value === field) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortField.value = field
|
||||
sortDirection.value = 'desc'
|
||||
}
|
||||
}
|
||||
|
||||
watch([total, perPage], ([count]) => {
|
||||
const lastPage = Math.max(1, Math.ceil(count / perPage.value))
|
||||
if (page.value > lastPage) page.value = lastPage
|
||||
})
|
||||
|
||||
// Per-user rollups behind the User hover card: lifetime credits and the most
|
||||
// recent event, aggregated across the whole (unpaged) list.
|
||||
const userSummaries = computed(() => {
|
||||
const map = new Map<string, UserSummary>()
|
||||
for (const event of base.value) {
|
||||
if (event.credited) continue
|
||||
const existing = map.get(event.userName)
|
||||
if (!existing) {
|
||||
map.set(event.userName, {
|
||||
totalCredits: event.credits,
|
||||
lastActivity: event.date
|
||||
})
|
||||
} else {
|
||||
existing.totalCredits += event.credits
|
||||
if (event.date > existing.lastActivity)
|
||||
existing.lastActivity = event.date
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
return {
|
||||
page,
|
||||
total,
|
||||
itemsPerPage: perPage,
|
||||
pagedItems,
|
||||
sortField,
|
||||
sortDirection,
|
||||
toggleSort,
|
||||
userSummaries
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { ActivityEvent } from '@/platform/workspace/composables/useWorkspaceActivity'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import type { BillingEventInput } from '@/platform/workspace/utils/billingEventToActivity'
|
||||
import {
|
||||
billingEventToActivity,
|
||||
isUsageEvent
|
||||
} from '@/platform/workspace/utils/billingEventToActivity'
|
||||
|
||||
/**
|
||||
* Live data source for the workspace Activity ledger (FE-1249): fetches the
|
||||
* per-workspace usage feed (`GET /api/billing/events`), keeps only usage rows,
|
||||
* and maps them to the ledger's `ActivityEvent` shape, resolving member names
|
||||
* from the workspace store so the mapping updates once members load.
|
||||
*/
|
||||
export function useWorkspaceActivitySource() {
|
||||
const { t } = useI18n()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const { members } = storeToRefs(workspaceStore)
|
||||
|
||||
const rawEvents = ref<BillingEventInput[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<unknown>(null)
|
||||
|
||||
function resolveUserName(userId: string | undefined): string {
|
||||
if (!userId) return ''
|
||||
const member = members.value.find((m) => m.id === userId)
|
||||
return member?.name || member?.email || userId
|
||||
}
|
||||
|
||||
const events = computed<ActivityEvent[]>(() => {
|
||||
const labels = {
|
||||
cloudRun: t('workspacePanel.activity.eventType.cloudRun'),
|
||||
partnerNode: t('workspacePanel.activity.eventType.partnerNode')
|
||||
}
|
||||
return rawEvents.value
|
||||
.filter(isUsageEvent)
|
||||
.map((event) => billingEventToActivity(event, resolveUserName, labels))
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await workspaceApi.getBillingEvents()
|
||||
rawEvents.value = response.events
|
||||
} catch (err) {
|
||||
error.value = err
|
||||
rawEvents.value = []
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refresh()
|
||||
})
|
||||
|
||||
return { events, isLoading, error, refresh }
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { useDialogService } from '@/services/dialogService'
|
||||
* Builds the Plan & Credits overflow-menu model for the workspace subscription
|
||||
* panel. Visibility and the Delete enable/disable policy are derived from the
|
||||
* shared useWorkspaceUI state so this menu can't desync with the sibling
|
||||
* WorkspacePanelContent menu.
|
||||
* Plan & Credits panel menu.
|
||||
*/
|
||||
export function useWorkspaceMenuItems() {
|
||||
const { t } = useI18n()
|
||||
|
||||
130
src/platform/workspace/fixtures/activityFixtures.ts
Normal file
130
src/platform/workspace/fixtures/activityFixtures.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { ActivityEvent } from '@/platform/workspace/composables/useWorkspaceActivity'
|
||||
|
||||
/**
|
||||
* Non-production sample ledger for Storybook and unit tests only — never
|
||||
* imported by shipping code. It stands in for the per-workspace usage API
|
||||
* (FE-1249) so the Activity table can be exercised with a populated,
|
||||
* multi-page, multi-user data set (usage outflows, partner-node rows, and
|
||||
* workspace-level credit inflows) while the live source is still empty.
|
||||
*/
|
||||
export const activityFixture: ActivityEvent[] = [
|
||||
{
|
||||
id: 'evt-01',
|
||||
date: new Date('2026-07-14T09:32:00Z'),
|
||||
userName: 'Ada Lovelace',
|
||||
eventType: 'Cloud workflow run',
|
||||
detail: '2 runs',
|
||||
credits: 1840
|
||||
},
|
||||
{
|
||||
id: 'evt-02',
|
||||
date: new Date('2026-07-14T08:10:00Z'),
|
||||
userName: 'Grace Hopper',
|
||||
eventType: 'Partner node usage',
|
||||
detail: '1 run',
|
||||
credits: 3200,
|
||||
partnerNode: 'Flux Pro 1.1 Ultra'
|
||||
},
|
||||
{
|
||||
id: 'evt-03',
|
||||
date: new Date('2026-07-13T22:47:00Z'),
|
||||
userName: 'Ada Lovelace',
|
||||
eventType: 'Partner node usage',
|
||||
detail: '4 runs',
|
||||
credits: 5120,
|
||||
partnerNode: 'Kling v2 Master'
|
||||
},
|
||||
{
|
||||
id: 'evt-04',
|
||||
date: new Date('2026-07-13T18:05:00Z'),
|
||||
userName: 'Alan Turing',
|
||||
eventType: 'Cloud workflow run',
|
||||
detail: '7 runs',
|
||||
credits: 6300
|
||||
},
|
||||
{
|
||||
id: 'evt-05',
|
||||
date: new Date('2026-07-13T12:00:00Z'),
|
||||
userName: '',
|
||||
eventType: 'Auto-reload',
|
||||
detail: '—',
|
||||
credits: 20000,
|
||||
credited: true
|
||||
},
|
||||
{
|
||||
id: 'evt-06',
|
||||
date: new Date('2026-07-12T16:22:00Z'),
|
||||
userName: 'Grace Hopper',
|
||||
eventType: 'Cloud workflow run',
|
||||
detail: '3 runs',
|
||||
credits: 2700
|
||||
},
|
||||
{
|
||||
id: 'evt-07',
|
||||
date: new Date('2026-07-12T11:41:00Z'),
|
||||
userName: 'Alan Turing',
|
||||
eventType: 'Partner node usage',
|
||||
detail: '2 runs',
|
||||
credits: 4480,
|
||||
partnerNode: 'Gemini 2.5 Flash Image'
|
||||
},
|
||||
{
|
||||
id: 'evt-08',
|
||||
date: new Date('2026-07-11T20:15:00Z'),
|
||||
userName: 'Ada Lovelace',
|
||||
eventType: 'Cloud workflow run',
|
||||
detail: '5 runs',
|
||||
credits: 4100
|
||||
},
|
||||
{
|
||||
id: 'evt-09',
|
||||
date: new Date('2026-07-11T09:03:00Z'),
|
||||
userName: 'Katherine Johnson',
|
||||
eventType: 'Cloud workflow run',
|
||||
detail: '1 run',
|
||||
credits: 760
|
||||
},
|
||||
{
|
||||
id: 'evt-10',
|
||||
date: new Date('2026-07-10T14:38:00Z'),
|
||||
userName: 'Grace Hopper',
|
||||
eventType: 'Partner node usage',
|
||||
detail: '6 runs',
|
||||
credits: 8900,
|
||||
partnerNode: 'Veo 3'
|
||||
},
|
||||
{
|
||||
id: 'evt-11',
|
||||
date: new Date('2026-07-10T07:26:00Z'),
|
||||
userName: 'Alan Turing',
|
||||
eventType: 'Cloud workflow run',
|
||||
detail: '2 runs',
|
||||
credits: 1560
|
||||
},
|
||||
{
|
||||
id: 'evt-12',
|
||||
date: new Date('2026-07-09T19:52:00Z'),
|
||||
userName: '',
|
||||
eventType: 'Top-up',
|
||||
detail: '—',
|
||||
credits: 50000,
|
||||
credited: true
|
||||
},
|
||||
{
|
||||
id: 'evt-13',
|
||||
date: new Date('2026-07-09T13:14:00Z'),
|
||||
userName: 'Katherine Johnson',
|
||||
eventType: 'Partner node usage',
|
||||
detail: '3 runs',
|
||||
credits: 3840,
|
||||
partnerNode: 'Recraft V3'
|
||||
},
|
||||
{
|
||||
id: 'evt-14',
|
||||
date: new Date('2026-07-08T10:48:00Z'),
|
||||
userName: 'Ada Lovelace',
|
||||
eventType: 'Cloud workflow run',
|
||||
detail: '4 runs',
|
||||
credits: 2950
|
||||
}
|
||||
]
|
||||
23
src/platform/workspace/utils/badgeColor.test.ts
Normal file
23
src/platform/workspace/utils/badgeColor.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { userBadgeColor } from './badgeColor'
|
||||
|
||||
const HEX = /^#[0-9a-f]{6}$/
|
||||
|
||||
describe('userBadgeColor', () => {
|
||||
it('is deterministic for the same seed', () => {
|
||||
expect(userBadgeColor('Ada Lovelace')).toBe(userBadgeColor('Ada Lovelace'))
|
||||
})
|
||||
|
||||
it('always returns a color from the palette', () => {
|
||||
for (const seed of ['', 'a', 'Grace Hopper', 'user@example.com', '你好']) {
|
||||
expect(userBadgeColor(seed)).toMatch(HEX)
|
||||
}
|
||||
})
|
||||
|
||||
it('spreads different seeds across more than one color', () => {
|
||||
const seeds = Array.from({ length: 30 }, (_, i) => `user-${i}`)
|
||||
const distinct = new Set(seeds.map(userBadgeColor))
|
||||
expect(distinct.size).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
21
src/platform/workspace/utils/badgeColor.ts
Normal file
21
src/platform/workspace/utils/badgeColor.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Muted, low-saturation badge palette (sampled from the Figma usage palette).
|
||||
// Dark tones that read against the dark surface with a white monogram.
|
||||
const BADGE_COLORS = [
|
||||
'#956252', // terracotta
|
||||
'#3e465f', // slate indigo
|
||||
'#424f45', // olive green
|
||||
'#90646e', // mauve rose
|
||||
'#6d5a7a', // muted purple
|
||||
'#4f6b6b', // muted teal
|
||||
'#7a6a4a', // khaki
|
||||
'#5a6270' // steel
|
||||
]
|
||||
|
||||
/** Stable muted badge color for a user, keyed by name/email. */
|
||||
export function userBadgeColor(seed: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0
|
||||
}
|
||||
return BADGE_COLORS[hash % BADGE_COLORS.length]
|
||||
}
|
||||
83
src/platform/workspace/utils/billingEventToActivity.test.ts
Normal file
83
src/platform/workspace/utils/billingEventToActivity.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { BillingEventInput } from '@/platform/workspace/utils/billingEventToActivity'
|
||||
import {
|
||||
billingEventToActivity,
|
||||
isUsageEvent
|
||||
} from '@/platform/workspace/utils/billingEventToActivity'
|
||||
|
||||
const labels = {
|
||||
cloudRun: 'Cloud workflow run',
|
||||
partnerNode: 'Partner node usage'
|
||||
}
|
||||
|
||||
const nameById: Record<string, string> = { 'user-1': 'Ada Lovelace' }
|
||||
|
||||
function resolveName(userId: string | undefined): string {
|
||||
if (!userId) return ''
|
||||
return nameById[userId] ?? userId
|
||||
}
|
||||
|
||||
function event(overrides: Partial<BillingEventInput>): BillingEventInput {
|
||||
return {
|
||||
event_type: 'gpu_usage',
|
||||
event_id: 'evt',
|
||||
createdAt: '2026-07-14T00:00:00Z',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('isUsageEvent', () => {
|
||||
it('keeps gpu and api-node usage and drops non-usage events', () => {
|
||||
expect(isUsageEvent(event({ event_type: 'gpu_usage' }))).toBe(true)
|
||||
expect(isUsageEvent(event({ event_type: 'api_node_usage' }))).toBe(true)
|
||||
expect(isUsageEvent(event({ event_type: 'invoice_paid' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('billingEventToActivity', () => {
|
||||
it('maps a GPU usage row to a cloud-run entry attributed to its member', () => {
|
||||
const row = billingEventToActivity(
|
||||
event({
|
||||
event_type: 'gpu_usage',
|
||||
event_id: 'evt-1',
|
||||
createdAt: '2026-07-14T09:32:00Z',
|
||||
params: { user_id: 'user-1', gpu_seconds: 12 }
|
||||
}),
|
||||
resolveName,
|
||||
labels
|
||||
)
|
||||
expect(row).toMatchObject({
|
||||
id: 'evt-1',
|
||||
userName: 'Ada Lovelace',
|
||||
eventType: 'Cloud workflow run',
|
||||
credits: 0,
|
||||
credited: false
|
||||
})
|
||||
expect(row.date.toISOString()).toBe('2026-07-14T09:32:00.000Z')
|
||||
expect(row.partnerNode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps a partner usage row with its partner node', () => {
|
||||
const row = billingEventToActivity(
|
||||
event({
|
||||
event_type: 'api_node_usage',
|
||||
event_id: 'evt-2',
|
||||
params: { user_id: 'user-1', partner_node: 'Flux Pro 1.1 Ultra' }
|
||||
}),
|
||||
resolveName,
|
||||
labels
|
||||
)
|
||||
expect(row.eventType).toBe('Partner node usage')
|
||||
expect(row.partnerNode).toBe('Flux Pro 1.1 Ultra')
|
||||
})
|
||||
|
||||
it('leaves the user name empty when the event has no user_id', () => {
|
||||
const row = billingEventToActivity(
|
||||
event({ event_id: 'evt-3', params: {} }),
|
||||
resolveName,
|
||||
labels
|
||||
)
|
||||
expect(row.userName).toBe('')
|
||||
})
|
||||
})
|
||||
57
src/platform/workspace/utils/billingEventToActivity.ts
Normal file
57
src/platform/workspace/utils/billingEventToActivity.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { ActivityEvent } from '@/platform/workspace/composables/useWorkspaceActivity'
|
||||
|
||||
/**
|
||||
* Maps a `/api/billing/events` usage row to an Activity ledger row (FE-1249).
|
||||
*
|
||||
* Only the fields the backend exposes today are populated: the date, the member
|
||||
* the event is attributed to (`params.user_id`), and the event type. The Credits
|
||||
* column stays 0 until per-event cost lands (FE-1249 / P1), and `partnerNode` is
|
||||
* best-effort from `params` because the partner (comfy-api) property contract is
|
||||
* not yet fixed.
|
||||
*/
|
||||
|
||||
export interface BillingEventInput {
|
||||
event_type: string
|
||||
event_id: string
|
||||
params?: Record<string, unknown>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ActivityEventTypeLabels {
|
||||
cloudRun: string
|
||||
partnerNode: string
|
||||
}
|
||||
|
||||
const USAGE_EVENT_TYPES = new Set(['gpu_usage', 'api_node_usage'])
|
||||
|
||||
export function isUsageEvent(event: BillingEventInput): boolean {
|
||||
return USAGE_EVENT_TYPES.has(event.event_type)
|
||||
}
|
||||
|
||||
function stringParam(
|
||||
params: Record<string, unknown> | undefined,
|
||||
key: string
|
||||
): string | undefined {
|
||||
const value = params?.[key]
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
export function billingEventToActivity(
|
||||
event: BillingEventInput,
|
||||
resolveUserName: (userId: string | undefined) => string,
|
||||
labels: ActivityEventTypeLabels
|
||||
): ActivityEvent {
|
||||
const isPartner = event.event_type === 'api_node_usage'
|
||||
return {
|
||||
id: event.event_id,
|
||||
date: new Date(event.createdAt),
|
||||
userName: resolveUserName(stringParam(event.params, 'user_id')),
|
||||
eventType: isPartner ? labels.partnerNode : labels.cloudRun,
|
||||
detail: '',
|
||||
credits: 0,
|
||||
partnerNode: isPartner
|
||||
? stringParam(event.params, 'partner_node')
|
||||
: undefined,
|
||||
credited: false
|
||||
}
|
||||
}
|
||||
44
src/platform/workspace/utils/relativeTime.test.ts
Normal file
44
src/platform/workspace/utils/relativeTime.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatRelativeTime } from './relativeTime'
|
||||
|
||||
const labels = {
|
||||
justNow: 'just now',
|
||||
minutesAgo: (n: number) => `${n} min ago`,
|
||||
hoursAgo: (n: number) => `${n} hr ago`,
|
||||
daysAgo: (n: number) => `${n} days ago`
|
||||
}
|
||||
|
||||
const now = new Date('2026-07-14T12:00:00Z')
|
||||
|
||||
function ago(ms: number) {
|
||||
return new Date(now.getTime() - ms)
|
||||
}
|
||||
|
||||
const SECOND = 1000
|
||||
const MINUTE = 60 * SECOND
|
||||
const HOUR = 60 * MINUTE
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
it('reads "just now" under a minute', () => {
|
||||
expect(formatRelativeTime(ago(30 * SECOND), now, labels)).toBe('just now')
|
||||
})
|
||||
|
||||
it('reads whole minutes under an hour', () => {
|
||||
expect(formatRelativeTime(ago(6 * MINUTE), now, labels)).toBe('6 min ago')
|
||||
expect(formatRelativeTime(ago(59 * MINUTE), now, labels)).toBe('59 min ago')
|
||||
})
|
||||
|
||||
it('reads whole hours under a day', () => {
|
||||
expect(formatRelativeTime(ago(2 * HOUR), now, labels)).toBe('2 hr ago')
|
||||
})
|
||||
|
||||
it('reads whole days beyond a day', () => {
|
||||
expect(formatRelativeTime(ago(3 * DAY), now, labels)).toBe('3 days ago')
|
||||
})
|
||||
|
||||
it('never returns a negative bucket for a future timestamp', () => {
|
||||
expect(formatRelativeTime(ago(-5 * MINUTE), now, labels)).toBe('just now')
|
||||
})
|
||||
})
|
||||
29
src/platform/workspace/utils/relativeTime.ts
Normal file
29
src/platform/workspace/utils/relativeTime.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
const MINUTE_MS = 60 * 1000
|
||||
const HOUR_MS = 60 * MINUTE_MS
|
||||
const DAY_MS = 24 * HOUR_MS
|
||||
|
||||
interface RelativeTimeLabels {
|
||||
justNow: string
|
||||
minutesAgo: (n: number) => string
|
||||
hoursAgo: (n: number) => string
|
||||
daysAgo: (n: number) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Abbreviated "time ago" label (e.g. "6 min ago", "2 hr ago", "3 days ago"),
|
||||
* matching the member-list activity column. Copy is injected so callers can
|
||||
* supply localized, pluralized strings.
|
||||
*/
|
||||
export function formatRelativeTime(
|
||||
date: Date,
|
||||
now: Date,
|
||||
labels: RelativeTimeLabels
|
||||
): string {
|
||||
const elapsed = Math.max(0, now.getTime() - date.getTime())
|
||||
|
||||
if (elapsed < MINUTE_MS) return labels.justNow
|
||||
if (elapsed < HOUR_MS)
|
||||
return labels.minutesAgo(Math.floor(elapsed / MINUTE_MS))
|
||||
if (elapsed < DAY_MS) return labels.hoursAgo(Math.floor(elapsed / HOUR_MS))
|
||||
return labels.daysAgo(Math.floor(elapsed / DAY_MS))
|
||||
}
|
||||
16
src/storybook/mocks/useCurrentUser.ts
Normal file
16
src/storybook/mocks/useCurrentUser.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Storybook mock for `useCurrentUser`.
|
||||
*
|
||||
* The real composable reads the Firebase-backed `authStore`, whose setup calls
|
||||
* `setPersistence` and crashes in the Storybook environment (no Firebase). This
|
||||
* static stub presents a signed-in user so components that only need the
|
||||
* display name / email (e.g. WorkspaceActivityContent's member self-scope)
|
||||
* render without any auth.
|
||||
*/
|
||||
export const useCurrentUser = () => ({
|
||||
userDisplayName: ref('Ada Lovelace'),
|
||||
userEmail: ref('ada@example.com'),
|
||||
isLoggedIn: computed(() => true)
|
||||
})
|
||||
17
src/storybook/mocks/useWorkspaceUI.ts
Normal file
17
src/storybook/mocks/useWorkspaceUI.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Storybook mock for `useWorkspaceUI`.
|
||||
*
|
||||
* The real composable derives its state from the auth/workspace stores that
|
||||
* pull in Firebase and crash in Storybook. This stub presents a billing
|
||||
* manager (owner) so role-gated surfaces — the Activity ledger's team-wide
|
||||
* scope and its per-user footer — render in their fully-populated form.
|
||||
*/
|
||||
export function useWorkspaceUI() {
|
||||
return {
|
||||
permissions: computed(() => ({ canManageSubscription: true })),
|
||||
workspaceRole: ref('owner'),
|
||||
workspaceType: ref('team')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user