mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-08 16:17:58 +00:00
Compare commits
98 Commits
main
...
comfydesig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce73c2480f | ||
|
|
3cb63e3209 | ||
|
|
7317b73a38 | ||
|
|
beb0a5e12b | ||
|
|
0124ea8d43 | ||
|
|
0be144d1d6 | ||
|
|
77330e8f60 | ||
|
|
8fb73ff290 | ||
|
|
674143721f | ||
|
|
52c7944f2c | ||
|
|
6dcc34b595 | ||
|
|
bef5616aba | ||
|
|
7b7cd0342f | ||
|
|
8d8d0ed61d | ||
|
|
d7e074bbf5 | ||
|
|
4daf17c9d7 | ||
|
|
ab275ba35e | ||
|
|
f5f365c58a | ||
|
|
64bcf2748e | ||
|
|
b2400f764d | ||
|
|
4925f0ca0c | ||
|
|
123bdad84c | ||
|
|
04e79dc2da | ||
|
|
9b54efdef4 | ||
|
|
cf3e4c4620 | ||
|
|
53c7566665 | ||
|
|
001b9f3ace | ||
|
|
2324417e22 | ||
|
|
9bbe75eeaf | ||
|
|
0ac06403d8 | ||
|
|
786ae99afa | ||
|
|
1e2cfd54c8 | ||
|
|
b206ca457f | ||
|
|
5dd04a5e3f | ||
|
|
5e5bf8891a | ||
|
|
e75f32b56f | ||
|
|
46a357113b | ||
|
|
d5ebe903bd | ||
|
|
c3c22d3e3a | ||
|
|
93dc76728e | ||
|
|
a0932ba40f | ||
|
|
af5e9d0766 | ||
|
|
c3ca88161c | ||
|
|
e499bfc172 | ||
|
|
96ac9f04c0 | ||
|
|
57f24854be | ||
|
|
3f9f2a7782 | ||
|
|
f5844a0a87 | ||
|
|
f01448d6b9 | ||
|
|
3a31a99eaf | ||
|
|
053d559d15 | ||
|
|
8135842cce | ||
|
|
6ecb13a6ce | ||
|
|
de95ef0fbd | ||
|
|
f03cd5de33 | ||
|
|
529248fb7e | ||
|
|
2c9bee1da3 | ||
|
|
ba494b3d4a | ||
|
|
2b49d78637 | ||
|
|
b579234d55 | ||
|
|
d9a5d28364 | ||
|
|
5ec65f366e | ||
|
|
5e9d1b89f9 | ||
|
|
4308498b79 | ||
|
|
3bd7681e84 | ||
|
|
1e44a207b9 | ||
|
|
ba99a98807 | ||
|
|
98e42e53b7 | ||
|
|
baf75706bd | ||
|
|
4242be2707 | ||
|
|
5c7ccbf2a4 | ||
|
|
21923f4e9a | ||
|
|
6e67964317 | ||
|
|
11be2af866 | ||
|
|
2c92b2cc6c | ||
|
|
ffb925837a | ||
|
|
af2f09304d | ||
|
|
b01c01625a | ||
|
|
fef7ad95f6 | ||
|
|
77624ad2b6 | ||
|
|
2872fd4062 | ||
|
|
6948ff3fc9 | ||
|
|
3e867da225 | ||
|
|
90d7d9f450 | ||
|
|
d52e97b0dc | ||
|
|
dc298c9ab8 | ||
|
|
9c3d2d3731 | ||
|
|
6f44b98153 | ||
|
|
51c213997f | ||
|
|
14c94879b1 | ||
|
|
fd6ec16c72 | ||
|
|
974d79f776 | ||
|
|
6636851f79 | ||
|
|
3655901721 | ||
|
|
6d91432abe | ||
|
|
2b8981a2d3 | ||
|
|
4779a21ce5 | ||
|
|
6b0e12a783 |
@@ -22,7 +22,11 @@ defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
const {
|
||||
itemClass: itemProp,
|
||||
contentClass: contentProp,
|
||||
modal = true
|
||||
} = defineProps<{
|
||||
entries?: MenuItem[]
|
||||
icon?: string
|
||||
to?: string | HTMLElement
|
||||
@@ -30,6 +34,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
contentClass?: string
|
||||
buttonSize?: ButtonVariants['size']
|
||||
buttonClass?: string
|
||||
modal?: boolean
|
||||
}>()
|
||||
|
||||
const itemClass = computed(() =>
|
||||
@@ -58,7 +63,7 @@ const contentStyle = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot v-model:open="open">
|
||||
<DropdownMenuRoot v-model:open="open" :modal>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
|
||||
|
||||
40
src/components/common/SelectionBar.vue
Normal file
40
src/components/common/SelectionBar.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="relative mx-2">
|
||||
<div
|
||||
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="{ value: deselectLabel, showDelay: 300 }"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
:aria-label="deselectLabel"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('deselect')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
|
||||
{{ label }}
|
||||
</span>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
/** The "N selected" text; the caller formats it (pluralization, wording). */
|
||||
label: string
|
||||
/** Accessible label + tooltip for the deselect button. */
|
||||
deselectLabel: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
deselect: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -318,6 +318,22 @@ describe('shouldPreventRekaDismiss', () => {
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('prevents dismiss when clicking a menu trigger to close it', () => {
|
||||
// A DropdownMenu trigger inside a dialog: clicking it again to close the
|
||||
// menu must not tear down the surrounding dialog.
|
||||
const trigger = document.createElement('button')
|
||||
trigger.setAttribute('aria-haspopup', 'menu')
|
||||
const icon = document.createElement('i')
|
||||
trigger.appendChild(icon)
|
||||
document.body.appendChild(trigger)
|
||||
|
||||
const event = makeEvent(icon)
|
||||
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
trigger.remove()
|
||||
})
|
||||
|
||||
it('prevents dismiss when the dialog is not the top-most (stacked)', () => {
|
||||
// A backgrounded dialog must never dismiss on an outside pointer — the
|
||||
// pointer belongs to the dialog stacked above it (e.g. Edit Keybinding
|
||||
|
||||
@@ -9,10 +9,11 @@ const PRIMEVUE_OVERLAY_SELECTORS =
|
||||
// Reka portals its own dialogs / popovers / menus into the body too. When a
|
||||
// nested Reka layer opens on top of a non-modal parent, the parent's
|
||||
// DismissableLayer sees the focus shift / pointer-down as "outside" and would
|
||||
// dismiss itself. These selectors cover the portaled roots so we can treat
|
||||
// interactions on them as inside.
|
||||
// dismiss itself. These selectors cover the portaled roots (and the triggers
|
||||
// that toggle them — clicking a menu/popup trigger to close it must not dismiss
|
||||
// the surrounding dialog) so we can treat interactions on them as inside.
|
||||
const REKA_PORTAL_SELECTORS =
|
||||
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"]'
|
||||
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"], [aria-haspopup="menu"], [aria-haspopup="dialog"], [aria-haspopup="listbox"]'
|
||||
|
||||
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`
|
||||
|
||||
|
||||
25
src/components/ui/checkbox/Checkbox.vue
Normal file
25
src/components/ui/checkbox/Checkbox.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
v-model="checked"
|
||||
:class="
|
||||
cn(
|
||||
'peer flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-[4px] border border-interface-stroke bg-transparent transition-colors focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-white',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<CheckboxIndicator class="flex items-center justify-center">
|
||||
<i class="icon-[lucide--check] size-3" />
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckboxIndicator, CheckboxRoot } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
const checked = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
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>
|
||||
@@ -35,7 +35,7 @@ export const searchInputSizeConfig = {
|
||||
icon: 'size-4',
|
||||
iconPos: 'left-2.5',
|
||||
inputPl: 'pl-8',
|
||||
inputText: 'text-xs',
|
||||
inputText: 'text-sm',
|
||||
clearPos: 'left-2.5'
|
||||
},
|
||||
xl: {
|
||||
|
||||
30
src/components/ui/switch/Switch.vue
Normal file
30
src/components/ui/switch/Switch.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-model="checked"
|
||||
:disabled
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent px-0.5 transition-colors focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
checked ? 'bg-primary' : 'bg-interface-stroke'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SwitchThumb
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none block size-4 rounded-full bg-white shadow-sm transition-transform',
|
||||
checked ? 'translate-x-3.5' : 'translate-x-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { disabled = false } = defineProps<{ disabled?: boolean }>()
|
||||
const checked = defineModel<boolean>({ default: false })
|
||||
</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', 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>
|
||||
20
src/components/ui/table/TableHead.vue
Normal file
20
src/components/ui/table/TableHead.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<th
|
||||
:class="
|
||||
cn(
|
||||
'h-10 px-2 text-left align-middle text-sm font-normal 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>
|
||||
16
src/components/ui/tabs/Tabs.vue
Normal file
16
src/components/ui/tabs/Tabs.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
|
||||
const props = defineProps<TabsRootProps>()
|
||||
const emits = defineEmits<TabsRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</TabsRoot>
|
||||
</template>
|
||||
20
src/components/ui/tabs/TabsList.vue
Normal file
20
src/components/ui/tabs/TabsList.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsList } from 'reka-ui'
|
||||
import type { TabsListProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...rest } = defineProps<
|
||||
TabsListProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsList
|
||||
v-bind="rest"
|
||||
:class="cn('inline-flex items-center gap-4', className)"
|
||||
>
|
||||
<slot />
|
||||
</TabsList>
|
||||
</template>
|
||||
28
src/components/ui/tabs/TabsTrigger.vue
Normal file
28
src/components/ui/tabs/TabsTrigger.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsTrigger, useForwardProps } from 'reka-ui'
|
||||
import type { TabsTriggerProps } from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...rest } = defineProps<
|
||||
TabsTriggerProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwarded = useForwardProps(computed(() => rest))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsTrigger
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer appearance-none border-0 border-b-2 border-transparent bg-transparent px-0 pb-2 text-sm text-muted-foreground transition-colors outline-none data-[state=active]:border-base-foreground data-[state=active]:text-base-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TabsTrigger>
|
||||
</template>
|
||||
@@ -14,7 +14,12 @@
|
||||
>
|
||||
<header
|
||||
data-component-id="LeftPanelHeader"
|
||||
class="flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6',
|
||||
headerHeightClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="leftPanelHeaderTitle" />
|
||||
<Button
|
||||
@@ -33,7 +38,12 @@
|
||||
<div class="flex flex-col overflow-hidden bg-base-background">
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="flex h-18 w-full items-center justify-between gap-2 px-6"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-18 w-full items-center justify-between gap-2 px-6',
|
||||
headerHeightClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 gap-2">
|
||||
<Button
|
||||
@@ -151,20 +161,22 @@ const SIZE_CLASSES = {
|
||||
} as const
|
||||
|
||||
type ModalSize = keyof typeof SIZE_CLASSES
|
||||
type ContentPadding = 'default' | 'compact' | 'none'
|
||||
type ContentPadding = 'default' | 'compact' | 'none' | 'flush'
|
||||
|
||||
const {
|
||||
contentTitle,
|
||||
rightPanelTitle,
|
||||
size = 'lg',
|
||||
leftPanelWidth = '14rem',
|
||||
contentPadding = 'default'
|
||||
contentPadding = 'default',
|
||||
headerHeightClass = 'h-18'
|
||||
} = defineProps<{
|
||||
contentTitle: string
|
||||
rightPanelTitle?: string
|
||||
size?: ModalSize
|
||||
leftPanelWidth?: string
|
||||
contentPadding?: ContentPadding
|
||||
headerHeightClass?: string
|
||||
}>()
|
||||
|
||||
const sizeClasses = computed(() => SIZE_CLASSES[size])
|
||||
@@ -204,7 +216,10 @@ const contentContainerClass = computed(() =>
|
||||
cn(
|
||||
'flex scrollbar-custom min-h-0 flex-1 flex-col overflow-y-auto',
|
||||
contentPadding === 'default' && 'px-6 pt-0 pb-10',
|
||||
contentPadding === 'compact' && 'px-6 pt-0 pb-2'
|
||||
contentPadding === 'compact' && 'px-6 pt-0 pb-2',
|
||||
// Keep the horizontal inset but let content run to the bottom edge (it
|
||||
// clips there instead of ending above a padding gap).
|
||||
contentPadding === 'flush' && 'px-6 pt-0'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -90,7 +90,9 @@ export function useExternalLink() {
|
||||
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
|
||||
githubElectron: 'https://github.com/Comfy-Org/electron',
|
||||
forum: 'https://forum.comfy.org/',
|
||||
comfyOrg: 'https://www.comfy.org/'
|
||||
comfyOrg: 'https://www.comfy.org/',
|
||||
teamPlanRequests:
|
||||
'https://comfy-org.portal.usepylon.com/forms/team-plan-requests'
|
||||
}
|
||||
|
||||
/** Common doc paths for use with buildDocsUrl */
|
||||
|
||||
@@ -1556,6 +1556,8 @@
|
||||
"3DViewer": "3DViewer",
|
||||
"Canvas Navigation": "Canvas Navigation",
|
||||
"PlanCredits": "Plan & Credits",
|
||||
"Members": "Members",
|
||||
"PartnerNodes": "Allowlist",
|
||||
"Vue Nodes": "Nodes 2.0",
|
||||
"VueNodes": "Nodes 2.0",
|
||||
"Nodes 2_0": "Nodes 2.0",
|
||||
@@ -2588,6 +2590,7 @@
|
||||
"remaining": "remaining",
|
||||
"refillsDate": "Refills {date}",
|
||||
"refillsNextCycle": "Refills next cycle",
|
||||
"refillPaused": "Refill paused",
|
||||
"creditsUsed": "{used} used",
|
||||
"creditsLeftOfTotal": "{remaining} left of {total}",
|
||||
"monthlyUsageProgress": "{used} of {total} monthly credits used",
|
||||
@@ -2845,6 +2848,21 @@
|
||||
"updatePassword": "Update Password"
|
||||
},
|
||||
"workspacePanel": {
|
||||
"billingStatus": {
|
||||
"warning": {
|
||||
"title": "Payment declined",
|
||||
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated."
|
||||
},
|
||||
"paused": {
|
||||
"title": "Subscription paused",
|
||||
"body": "This workspace's subscription is paused. Update payment to resume.",
|
||||
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method."
|
||||
},
|
||||
"updatePayment": "Update payment"
|
||||
},
|
||||
"charactersLeft": "{count} character left | {count} characters left",
|
||||
"doubleClickToRename": "Double-click to rename",
|
||||
"editWorkspaceImage": "Edit workspace image",
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
@@ -2856,18 +2874,56 @@
|
||||
"dashboard": {
|
||||
"placeholder": "Dashboard workspace settings"
|
||||
},
|
||||
"partnerNodes": {
|
||||
"description": "Choose which partner nodes your team can use. Workflows with disabled nodes cannot be run.",
|
||||
"searchPlaceholder": "Search partner nodes",
|
||||
"selectAll": "Select all partner nodes",
|
||||
"columns": {
|
||||
"name": "Partner Node",
|
||||
"partner": "Partner",
|
||||
"lastModified": "Last modified"
|
||||
},
|
||||
"neverModified": "—",
|
||||
"empty": "No partner nodes match your search.",
|
||||
"selectedCount": "{count} node selected | {count} nodes selected",
|
||||
"clearSelection": "Clear selection",
|
||||
"autoEnableLabel": "When a new partner node is added, it should be:",
|
||||
"autoEnabled": "auto-enabled",
|
||||
"autoDisabled": "auto-disabled",
|
||||
"loadError": "Failed to load partner nodes",
|
||||
"updateError": "Failed to update partner nodes"
|
||||
},
|
||||
"members": {
|
||||
"header": "Members",
|
||||
"membersCount": "{count} of {maxSeats} members",
|
||||
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
|
||||
"tabs": {
|
||||
"active": "Active",
|
||||
"membersCount": "Members ({count})",
|
||||
"pending": "Pending",
|
||||
"pendingCount": "Pending ({count})"
|
||||
},
|
||||
"membersUsage": "{count} of {max} total members.",
|
||||
"columns": {
|
||||
"email": "Email",
|
||||
"inviteDate": "Invite date",
|
||||
"expiryDate": "Expiry date",
|
||||
"role": "Role"
|
||||
"role": "Role",
|
||||
"lastActivity": "Last activity",
|
||||
"creditsUsed": "Credits used this month"
|
||||
},
|
||||
"activity": {
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{n} min ago",
|
||||
"hoursAgo": "{n} hr ago",
|
||||
"daysAgo": "{count} day ago | {count} days ago"
|
||||
},
|
||||
"outOfCredits": {
|
||||
"title": "Out of credits",
|
||||
"body": "Your team has used all its credits. Workflow runs are blocked until additional credits are added.",
|
||||
"bodyWithReset": "Your team has used all its credits. Workflow runs are blocked until additional credits are added or the cycle resets on {date}.",
|
||||
"dismiss": "Dismiss",
|
||||
"addCredits": "Add credits"
|
||||
},
|
||||
"actions": {
|
||||
"resendInvite": "Resend invite",
|
||||
@@ -2887,6 +2943,7 @@
|
||||
},
|
||||
"menu": {
|
||||
"editWorkspace": "Edit workspace details",
|
||||
"renameWorkspace": "Rename Workspace",
|
||||
"leaveWorkspace": "Leave Workspace",
|
||||
"deleteWorkspace": "Delete Workspace",
|
||||
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
|
||||
@@ -2897,6 +2954,110 @@
|
||||
"nameLabel": "Workspace name",
|
||||
"save": "Save"
|
||||
},
|
||||
"requestMore": "Request more",
|
||||
"planCredits": {
|
||||
"tabs": {
|
||||
"overview": "Overview",
|
||||
"activity": "Activity",
|
||||
"invoices": "Invoices"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"perMonth": "mo",
|
||||
"renewsOn": "Renews on {date}",
|
||||
"managePayment": "Manage payment",
|
||||
"changePlan": "Change plan",
|
||||
"usd": "USD",
|
||||
"nextInvoice": "Next month invoice",
|
||||
"seeMore": "See more",
|
||||
"learnMore": "Learn more",
|
||||
"pricingTable": "Partner Nodes pricing table",
|
||||
"messageSupport": "Message support",
|
||||
"snapshot": {
|
||||
"user": "User",
|
||||
"lastActivity": "Last activity",
|
||||
"creditsUsed": "Credits used",
|
||||
"topSpenders": "Top spenders",
|
||||
"recentActivity": "Recent activity",
|
||||
"empty": {
|
||||
"title": "No credits used yet this month"
|
||||
}
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"columns": {
|
||||
"date": "Date",
|
||||
"user": "User",
|
||||
"eventType": "Event type",
|
||||
"eventDetails": "Event details",
|
||||
"creditsUsed": "Credits used"
|
||||
},
|
||||
"hoverCard": {
|
||||
"totalCreditsUsed": "Total credits used",
|
||||
"lastActivity": "Last activity",
|
||||
"partnerNodeUsed": "Partner node used"
|
||||
},
|
||||
"fullActivity": "Full activity",
|
||||
"perUserHint": "Looking for total usage per user?",
|
||||
"seeMembers": "See Members"
|
||||
},
|
||||
"invoices": {
|
||||
"columns": {
|
||||
"date": "Date",
|
||||
"eventType": "Event type",
|
||||
"price": "Price"
|
||||
},
|
||||
"fullHistory": "Full invoice history"
|
||||
},
|
||||
"autoReload": {
|
||||
"title": "Credit auto-reload",
|
||||
"subtitle": "Automatically add credits when your balance runs low, within an optional monthly budget.",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"edit": "Edit",
|
||||
"empty": {
|
||||
"body": "Keep your workflows running with auto-reloaded credits. Set a monthly budget so charges don't surprise you.",
|
||||
"cta": "Set up auto-reload"
|
||||
},
|
||||
"tile": {
|
||||
"label": "Auto-reload",
|
||||
"whenBelow": "when credits drop below",
|
||||
"monthlyBudget": "Monthly budget",
|
||||
"resets": "Resets {date}",
|
||||
"spent": "{amount} spent",
|
||||
"leftOfBudget": "{left} left of {budget} budget",
|
||||
"reloadsLeft": "{count} reload left | {count} reloads left",
|
||||
"recentReload": "Recent reload: {date}",
|
||||
"noRecentReload": "No reloads yet"
|
||||
},
|
||||
"badge": {
|
||||
"paused": "Paused",
|
||||
"off": "Off"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Auto-reload credits",
|
||||
"thresholdLabel": "When credits drop below:",
|
||||
"amountLabel": "Add this amount of credits:",
|
||||
"budgetToggleLabel": "Monthly budget",
|
||||
"budgetToggleHint": "Limit how much is auto-reloaded per month",
|
||||
"budgetPlaceholderCredits": "Enter an amount of credits",
|
||||
"budgetPlaceholderUsd": "Enter an amount of dollars",
|
||||
"minReload": "Minimum amount is {amount}",
|
||||
"allowsReloads": "Allows {count} reload /mo | Allows {count} reloads /mo",
|
||||
"credits": "Credits",
|
||||
"usd": "USD",
|
||||
"cancel": "Cancel",
|
||||
"update": "Update"
|
||||
}
|
||||
},
|
||||
"memberLimitDialog": {
|
||||
"title": "Workspace is at the member limit",
|
||||
"message": "All {count} seats are filled. To invite someone new, remove a member or request more seats."
|
||||
},
|
||||
"workflowQueuedDialog": {
|
||||
"title": "Your workflow is queued",
|
||||
"message": "Max workflow capacity reached. It'll start automatically as capacity opens up. If this happens often, you can also request for more capacity."
|
||||
},
|
||||
"leaveDialog": {
|
||||
"title": "Leave this workspace?",
|
||||
"message": "You won't be able to join again unless you contact the workspace owner.",
|
||||
@@ -2915,12 +3076,12 @@
|
||||
"error": "Failed to remove member"
|
||||
},
|
||||
"changeRoleDialog": {
|
||||
"promoteTitle": "Make {name} an owner?",
|
||||
"promoteTitle": "Make {name} an admin?",
|
||||
"promoteIntro": "They'll be able to:",
|
||||
"promotePermissionCredits": "Add additional credits",
|
||||
"promotePermissionManage": "Manage members, payment methods, and workspace settings",
|
||||
"promotePermissionRoles": "Promote and demote other owners (except the workspace creator).",
|
||||
"promoteConfirm": "Make owner",
|
||||
"promotePermissionRoles": "Promote and demote other admins (except the workspace owner).",
|
||||
"promoteConfirm": "Make admin",
|
||||
"demoteTitle": "Demote {name} to member?",
|
||||
"demoteMessage": "They'll lose admin access.",
|
||||
"demoteConfirm": "Demote to member",
|
||||
@@ -2991,13 +3152,14 @@
|
||||
"newWorkspace": "New workspace",
|
||||
"namePlaceholder": "e.g. Marketing Team",
|
||||
"createWorkspace": "Create workspace",
|
||||
"nameValidationError": "Name must be 1–50 characters using letters, numbers, spaces, or common punctuation."
|
||||
"nameValidationError": "Name must be 1–30 characters using letters, numbers, spaces, or common punctuation."
|
||||
},
|
||||
"workspaceSwitcher": {
|
||||
"switchWorkspace": "Switch workspace",
|
||||
"subscribe": "Subscribe",
|
||||
"personal": "Personal",
|
||||
"roleOwner": "Owner",
|
||||
"roleAdmin": "Admin",
|
||||
"roleMember": "Member",
|
||||
"createWorkspace": "Create a workspace",
|
||||
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.",
|
||||
|
||||
@@ -23,6 +23,7 @@ import '@/lib/litegraph/public/css/litegraph.css'
|
||||
import router from '@/router'
|
||||
import { isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { installBillingMockHarness } from '@/platform/workspace/dev/billingMockHarness'
|
||||
import { useBootstrapStore } from '@/stores/bootstrapStore'
|
||||
|
||||
import App from './App.vue'
|
||||
@@ -33,6 +34,10 @@ import { i18n } from './i18n'
|
||||
const isCloud = __DISTRIBUTION__ === 'cloud'
|
||||
const hasHostTelemetryBridge = Boolean(window.__comfyDesktop2?.Telemetry)
|
||||
|
||||
// PREVIEW HARNESS (do not merge): no-op unless `?billingmock`. Must run before
|
||||
// /api/features is fetched so the team-workspaces flag merge lands.
|
||||
installBillingMockHarness()
|
||||
|
||||
// Load remote config before initializeApp() below, so getFirebaseConfig() resolves
|
||||
// against the server's runtime values instead of the build-time defaults.
|
||||
const { refreshRemoteConfig } =
|
||||
|
||||
@@ -1,67 +1,48 @@
|
||||
<template>
|
||||
<div class="relative mx-2">
|
||||
<div
|
||||
data-testid="assets-selection-bar"
|
||||
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
|
||||
<SelectionBar
|
||||
data-testid="assets-selection-bar"
|
||||
:label="$t('mediaAsset.selection.selectedCount', { count })"
|
||||
:deselect-label="$t('mediaAsset.selection.deselectAll')"
|
||||
@deselect="emit('deselect')"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.downloadSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-download-selected"
|
||||
:aria-label="$t('mediaAsset.selection.downloadSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<template v-if="showDelete">
|
||||
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.deselectAll'),
|
||||
value: $t('mediaAsset.selection.deleteSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-deselect-selected"
|
||||
:aria-label="$t('mediaAsset.selection.deselectAll')"
|
||||
data-testid="assets-delete-selected"
|
||||
:aria-label="$t('mediaAsset.selection.deleteSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('deselect')"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
|
||||
{{ $t('mediaAsset.selection.selectedCount', { count }) }}
|
||||
</span>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.downloadSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-download-selected"
|
||||
:aria-label="$t('mediaAsset.selection.downloadSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<template v-if="showDelete">
|
||||
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: $t('mediaAsset.selection.deleteSelected'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
data-testid="assets-delete-selected"
|
||||
:aria-label="$t('mediaAsset.selection.deleteSelected')"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SelectionBar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SelectionBar from '@/components/common/SelectionBar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { count, showDelete = true } = defineProps<{
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<template>
|
||||
<div
|
||||
class="@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5"
|
||||
:class="
|
||||
cn(
|
||||
'@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5',
|
||||
customClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
@@ -19,8 +24,10 @@
|
||||
</div>
|
||||
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
|
||||
<div v-else class="flex items-baseline gap-2">
|
||||
<i class="icon-[lucide--component] size-4 self-center text-credit" />
|
||||
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
|
||||
<i class="icon-[lucide--coins] size-4 self-center text-credit" />
|
||||
<span class="text-2xl leading-none font-bold tabular-nums">{{
|
||||
displayTotal
|
||||
}}</span>
|
||||
<span class="text-sm text-muted @max-[300px]:hidden">{{
|
||||
$t('subscription.remaining')
|
||||
}}</span>
|
||||
@@ -73,15 +80,15 @@
|
||||
width="5rem"
|
||||
height="1rem"
|
||||
/>
|
||||
<span v-else class="text-muted @max-[300px]:hidden">
|
||||
<span v-else class="text-muted tabular-nums @max-[300px]:hidden">
|
||||
{{ $t('subscription.creditsUsed', { used: usedDisplay }) }}
|
||||
</span>
|
||||
<Skeleton v-if="isLoadingBalance" width="9rem" height="1rem" />
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center gap-1 font-bold text-text-primary"
|
||||
class="flex items-center gap-1 font-bold text-text-primary tabular-nums"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4 text-credit" />
|
||||
<i class="icon-[lucide--coins] size-4 text-credit" />
|
||||
<span class="@max-[180px]:hidden">
|
||||
{{
|
||||
$t('subscription.creditsLeftOfTotal', {
|
||||
@@ -118,7 +125,7 @@
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('subscription.additionalCreditsInfo')"
|
||||
class="text-muted"
|
||||
class="flex cursor-help appearance-none items-center border-none bg-transparent p-0 text-muted transition-colors hover:text-text-primary"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-4" />
|
||||
</Button>
|
||||
@@ -132,9 +139,9 @@
|
||||
<Skeleton v-if="isLoadingBalance" width="3rem" height="1rem" />
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center gap-1 font-bold text-text-primary"
|
||||
class="flex items-center gap-1 font-bold text-text-primary tabular-nums"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4 text-credit" />
|
||||
<i class="icon-[lucide--coins] size-4 text-credit" />
|
||||
{{ displayPrepaid }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -156,15 +163,10 @@
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
:variant="isOutOfCredits ? 'inverted' : 'secondary'"
|
||||
:variant="isOutOfCredits ? 'inverted' : 'tertiary'"
|
||||
size="lg"
|
||||
:class="
|
||||
cn(
|
||||
'w-full font-normal',
|
||||
!isOutOfCredits &&
|
||||
'bg-interface-menu-component-surface-selected text-text-primary'
|
||||
)
|
||||
"
|
||||
class="w-full font-normal"
|
||||
:disabled="isPaused"
|
||||
@click="handleAddCredits"
|
||||
>
|
||||
{{ $t('subscription.addCredits') }}
|
||||
@@ -178,6 +180,7 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatCredits } from '@/base/credits/comfyCredits'
|
||||
@@ -197,15 +200,17 @@ import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { zeroState = false } = defineProps<{
|
||||
const { zeroState = false, class: customClass } = defineProps<{
|
||||
/** Forces the zero-credit display (e.g. unsubscribed / member view). */
|
||||
zeroState?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
const {
|
||||
subscription,
|
||||
subscriptionStatus,
|
||||
balance,
|
||||
isActiveSubscription,
|
||||
isFreeTier,
|
||||
@@ -227,6 +232,11 @@ const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const dialogService = useDialogService()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
// Paused (failed payment): the plan still exists but refills are frozen, so the
|
||||
// tile drops the out-of-credits messaging, shows an empty bar, and disables the
|
||||
// top-up action — adding credits doesn't help until payment is resolved.
|
||||
const isPaused = computed(() => subscriptionStatus.value === 'paused')
|
||||
|
||||
const tierKey = computed(() => {
|
||||
const tier = subscription.value?.tier
|
||||
if (!tier) return DEFAULT_TIER_KEY
|
||||
@@ -239,12 +249,13 @@ const monthlyTotalCredits = computed<number | null>(() => {
|
||||
return getTierCredits(tierKey.value)
|
||||
})
|
||||
|
||||
const usage = computed(() =>
|
||||
computeMonthlyUsage(
|
||||
const usage = computed(() => {
|
||||
const base = computeMonthlyUsage(
|
||||
monthlyBonusCreditsValue.value,
|
||||
monthlyTotalCredits.value ?? 0
|
||||
)
|
||||
)
|
||||
return isPaused.value ? { ...base, used: 0, usedFraction: 0 } : base
|
||||
})
|
||||
|
||||
const refillsDateShort = computed(() => {
|
||||
const raw = subscription.value?.renewalDate
|
||||
@@ -257,11 +268,12 @@ const refillsDateShort = computed(() => {
|
||||
|
||||
const hasRefillsDate = computed(() => refillsDateShort.value !== '')
|
||||
|
||||
const refillsLabel = computed(() =>
|
||||
hasRefillsDate.value
|
||||
const refillsLabel = computed(() => {
|
||||
if (isPaused.value) return t('subscription.refillPaused')
|
||||
return hasRefillsDate.value
|
||||
? t('subscription.refillsDate', { date: refillsDateShort.value })
|
||||
: t('subscription.refillsNextCycle')
|
||||
)
|
||||
})
|
||||
|
||||
const formatCreditCount = (value: number) =>
|
||||
formatCredits({
|
||||
@@ -313,6 +325,7 @@ const showActionButton = computed(
|
||||
|
||||
const isMonthlyDepleted = computed(
|
||||
() =>
|
||||
!isPaused.value &&
|
||||
showBar.value &&
|
||||
!isLoadingBalance.value &&
|
||||
balance.value != null &&
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<BaseModalLayout content-title="" data-testid="settings-dialog" size="full">
|
||||
<BaseModalLayout
|
||||
content-title=""
|
||||
data-testid="settings-dialog"
|
||||
size="full"
|
||||
header-height-class="h-22"
|
||||
:content-padding="activeCategoryKey === 'workspace' ? 'flush' : 'default'"
|
||||
>
|
||||
<template #leftPanelHeaderTitle>
|
||||
<i class="icon-[lucide--settings]" />
|
||||
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
|
||||
@@ -48,6 +54,7 @@
|
||||
id="keybinding-panel-header"
|
||||
class="flex-1"
|
||||
/>
|
||||
<WorkspaceSettingsHeader v-else-if="isWorkspacePanel" />
|
||||
</template>
|
||||
|
||||
<template #header-right-area>
|
||||
@@ -55,6 +62,7 @@
|
||||
v-if="activeCategoryKey === 'keybinding'"
|
||||
id="keybinding-panel-actions"
|
||||
/>
|
||||
<WorkspaceMenuButton v-else-if="isWorkspacePanel" />
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
@@ -93,8 +101,11 @@ import NavTitle from '@/components/widget/nav/NavTitle.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
|
||||
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
|
||||
import WorkspaceMenuButton from '@/platform/workspace/components/dialogs/settings/WorkspaceMenuButton.vue'
|
||||
import WorkspaceSettingsHeader from '@/platform/workspace/components/dialogs/settings/WorkspaceSettingsHeader.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 +146,14 @@ const { fetchBalance } = useBillingContext()
|
||||
const navRef = ref<HTMLElement | null>(null)
|
||||
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
|
||||
|
||||
// Let panels deep-link into a sibling panel (e.g. Overview → 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) => ({
|
||||
@@ -172,6 +191,17 @@ const activePanel = computed(() => {
|
||||
return findPanelByKey(activeCategoryKey.value)
|
||||
})
|
||||
|
||||
const WORKSPACE_PANEL_KEYS = [
|
||||
'workspace',
|
||||
'workspace-members',
|
||||
'workspace-partner-nodes'
|
||||
]
|
||||
const isWorkspacePanel = computed(
|
||||
() =>
|
||||
!!activeCategoryKey.value &&
|
||||
WORKSPACE_PANEL_KEYS.includes(activeCategoryKey.value)
|
||||
)
|
||||
|
||||
const getGroupSortOrder = (group: SettingTreeNode): number =>
|
||||
Math.max(0, ...flattenTree<SettingParams>(group).map((s) => s.sortOrder ?? 0))
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ const env = vi.hoisted(() => {
|
||||
teamWorkspacesEnabled: false,
|
||||
userSecretsEnabled: false,
|
||||
isActiveSubscription: false,
|
||||
billingType: 'legacy' as 'legacy' | 'workspace'
|
||||
billingType: 'legacy' as 'legacy' | 'workspace',
|
||||
canManagePartnerNodes: false
|
||||
}
|
||||
const fakeRef = <K extends keyof typeof state>(key: K) => ({
|
||||
get value() {
|
||||
@@ -75,6 +76,16 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
getSettingInfo: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
|
||||
useWorkspaceUI: () => ({
|
||||
permissions: {
|
||||
get value() {
|
||||
return { canManagePartnerNodes: env.state.canManagePartnerNodes }
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
interface MockSettingParams {
|
||||
id: string
|
||||
name: string
|
||||
@@ -116,7 +127,8 @@ describe('useSettingUI', () => {
|
||||
teamWorkspacesEnabled: false,
|
||||
userSecretsEnabled: false,
|
||||
isActiveSubscription: false,
|
||||
billingType: 'legacy'
|
||||
billingType: 'legacy',
|
||||
canManagePartnerNodes: false
|
||||
})
|
||||
|
||||
vi.mocked(useSettingStore).mockReturnValue({
|
||||
@@ -233,5 +245,24 @@ describe('useSettingUI', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('splits the workspace panel into plan and members sidebar entries', () => {
|
||||
const { navGroups } = useSettingUI()
|
||||
const keys = navKeys(navGroups.value)
|
||||
expect(keys).toContain('workspace')
|
||||
expect(keys).toContain('workspace-members')
|
||||
})
|
||||
|
||||
it('shows the partner nodes entry only to owners and admins', () => {
|
||||
env.state.canManagePartnerNodes = false
|
||||
expect(navKeys(useSettingUI().navGroups.value)).not.toContain(
|
||||
'workspace-partner-nodes'
|
||||
)
|
||||
|
||||
env.state.canManagePartnerNodes = true
|
||||
expect(navKeys(useSettingUI().navGroups.value)).toContain(
|
||||
'workspace-partner-nodes'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/platform/settings/settingStore'
|
||||
import type { SettingTreeNode } from '@/platform/settings/settingStore'
|
||||
import type { SettingPanelType, SettingParams } from '@/platform/settings/types'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import type { NavGroupData } from '@/types/navTypes'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
@@ -27,13 +28,15 @@ 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]',
|
||||
PartnerNodes: 'icon-[lucide--shield-check]',
|
||||
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 {
|
||||
@@ -54,6 +57,7 @@ export function useSettingUI(
|
||||
const { flags } = useFeatureFlags()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const { isActiveSubscription, type: billingType } = useBillingContext()
|
||||
const { permissions } = useWorkspaceUI()
|
||||
|
||||
const teamWorkspacesEnabled = computed(
|
||||
() => isCloud && flags.teamWorkspacesEnabled
|
||||
@@ -175,16 +179,42 @@ 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 three 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')
|
||||
)
|
||||
}
|
||||
|
||||
const partnerNodesPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'workspace-partner-nodes',
|
||||
label: 'PartnerNodes',
|
||||
children: []
|
||||
},
|
||||
component: defineAsyncComponent(
|
||||
() =>
|
||||
import('@/platform/workspace/components/dialogs/settings/PartnerNodesPanelContent.vue')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -192,6 +222,12 @@ export function useSettingUI(
|
||||
() => teamWorkspacesEnabled.value && isLoggedIn.value
|
||||
)
|
||||
|
||||
// Partner-node governance is Owner/Admin-only; Members never see the tab.
|
||||
const shouldShowPartnerNodesPanel = computed(
|
||||
() =>
|
||||
shouldShowWorkspacePanel.value && permissions.value.canManagePartnerNodes
|
||||
)
|
||||
|
||||
const secretsPanel: SettingPanelItem = {
|
||||
node: {
|
||||
key: 'secrets',
|
||||
@@ -245,7 +281,10 @@ export function useSettingUI(
|
||||
aboutPanel,
|
||||
creditsPanel,
|
||||
userPanel,
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
|
||||
...(shouldShowWorkspacePanel.value
|
||||
? [planCreditsPanel, membersPanel]
|
||||
: []),
|
||||
...(shouldShowPartnerNodesPanel.value ? [partnerNodesPanel] : []),
|
||||
keybindingPanel,
|
||||
extensionPanel,
|
||||
...(isDesktop ? [serverConfigPanel] : []),
|
||||
@@ -295,8 +334,14 @@ export function useSettingUI(
|
||||
key: 'workspace',
|
||||
label: 'Workspace',
|
||||
children: [
|
||||
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
|
||||
...(isLoggedIn.value &&
|
||||
...(shouldShowWorkspacePanel.value
|
||||
? [planCreditsPanel.node, membersPanel.node]
|
||||
: []),
|
||||
...(shouldShowPartnerNodesPanel.value ? [partnerNodesPanel.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. Overview → Members).
|
||||
const requestedPanelKey = ref<string | null>(null)
|
||||
|
||||
export function useSettingsNavigation() {
|
||||
function navigateToPanel(key: string) {
|
||||
requestedPanelKey.value = key
|
||||
}
|
||||
|
||||
return { requestedPanelKey, navigateToPanel }
|
||||
}
|
||||
@@ -87,3 +87,5 @@ export type SettingPanelType =
|
||||
| 'subscription'
|
||||
| 'user'
|
||||
| 'workspace'
|
||||
| 'workspace-members'
|
||||
| 'workspace-partner-nodes'
|
||||
|
||||
88
src/platform/workspace/api/partnerNodesApi.ts
Normal file
88
src/platform/workspace/api/partnerNodesApi.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
/** A partner (paid-API) node the workspace can allow or block. */
|
||||
export interface PartnerNode {
|
||||
id: string
|
||||
name: string
|
||||
partner: string
|
||||
/** ISO date of the last governance change, or null if never modified. */
|
||||
last_modified: string | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface PartnerNodesResponse {
|
||||
partner_nodes: PartnerNode[]
|
||||
/** Workspace default applied to newly added partner nodes. */
|
||||
auto_enable_new: boolean
|
||||
}
|
||||
|
||||
interface SetEnabledPayload {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface BulkSetEnabledPayload {
|
||||
node_ids: string[]
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface SetAutoEnablePayload {
|
||||
auto_enable_new: boolean
|
||||
}
|
||||
|
||||
const partnerNodesApiClient = axios.create({
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
attachUnifiedRemintInterceptor(partnerNodesApiClient)
|
||||
|
||||
async function authHeader() {
|
||||
return useAuthStore().getAuthHeaderOrThrow()
|
||||
}
|
||||
|
||||
export const partnerNodesApi = {
|
||||
/** GET /api/workspace/partner-nodes */
|
||||
async list(): Promise<PartnerNodesResponse> {
|
||||
const headers = await authHeader()
|
||||
const response = await partnerNodesApiClient.get<PartnerNodesResponse>(
|
||||
api.apiURL('/workspace/partner-nodes'),
|
||||
{ headers }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** PATCH /api/workspace/partner-nodes/:id */
|
||||
async setEnabled(nodeId: string, enabled: boolean): Promise<void> {
|
||||
const headers = await authHeader()
|
||||
const payload: SetEnabledPayload = { enabled }
|
||||
await partnerNodesApiClient.patch(
|
||||
api.apiURL(`/workspace/partner-nodes/${nodeId}`),
|
||||
payload,
|
||||
{ headers }
|
||||
)
|
||||
},
|
||||
|
||||
/** PATCH /api/workspace/partner-nodes (bulk) */
|
||||
async setEnabledBulk(nodeIds: string[], enabled: boolean): Promise<void> {
|
||||
const headers = await authHeader()
|
||||
const payload: BulkSetEnabledPayload = { node_ids: nodeIds, enabled }
|
||||
await partnerNodesApiClient.patch(
|
||||
api.apiURL('/workspace/partner-nodes'),
|
||||
payload,
|
||||
{ headers }
|
||||
)
|
||||
},
|
||||
|
||||
/** PUT /api/workspace/partner-nodes/settings */
|
||||
async setAutoEnableNew(autoEnableNew: boolean): Promise<void> {
|
||||
const headers = await authHeader()
|
||||
const payload: SetAutoEnablePayload = { auto_enable_new: autoEnableNew }
|
||||
await partnerNodesApiClient.put(
|
||||
api.apiURL('/workspace/partner-nodes/settings'),
|
||||
payload,
|
||||
{ headers }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,11 @@ export interface Member {
|
||||
// billing lifecycle actions (cancel / reactivate / downgrade).
|
||||
// Optional: the cloud OpenAPI does not carry this field yet.
|
||||
is_original_owner?: boolean
|
||||
// Last time the member ran or interacted with the workspace, and the credits
|
||||
// they've consumed in the current billing cycle. Optional: the cloud OpenAPI
|
||||
// does not carry these fields yet.
|
||||
last_active_at?: string | null
|
||||
credits_used_this_month?: number
|
||||
}
|
||||
|
||||
interface PaginationInfo {
|
||||
@@ -244,6 +249,7 @@ export type BillingSubscriptionStatus =
|
||||
| 'scheduled'
|
||||
| 'ended'
|
||||
| 'canceled'
|
||||
| 'paused'
|
||||
|
||||
export type BillingStatus =
|
||||
| 'awaiting_payment_method'
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Loading state while subscription is being set up -->
|
||||
<div
|
||||
v-if="isSettingUp"
|
||||
class="rounded-2xl border border-interface-stroke p-6"
|
||||
class="rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-2 py-4 text-muted-foreground">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
@@ -14,7 +14,7 @@
|
||||
<!-- Billing data still loading: avoid rendering a false Free/$0 plan -->
|
||||
<div
|
||||
v-else-if="isLoading && !subscription"
|
||||
class="rounded-2xl border border-interface-stroke p-6"
|
||||
class="rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-2 py-4 text-muted-foreground">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
@@ -25,7 +25,7 @@
|
||||
<!-- Billing fetch failed: offer retry rather than a misleading Free plan -->
|
||||
<div
|
||||
v-else-if="error && !subscription"
|
||||
class="flex flex-col items-start gap-3 rounded-2xl border border-interface-stroke p-6"
|
||||
class="flex flex-col items-start gap-3 rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-text-secondary">
|
||||
<i class="pi pi-exclamation-circle text-danger" />
|
||||
@@ -67,7 +67,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-interface-stroke p-6">
|
||||
<div class="rounded-2xl border border-interface-stroke/60 p-6">
|
||||
<div>
|
||||
<div
|
||||
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex aspect-square size-8 items-center justify-center rounded-md text-base font-semibold text-white"
|
||||
:style="{
|
||||
background: gradient,
|
||||
textShadow: '0 1px 2px rgba(0, 0, 0, 0.2)'
|
||||
}"
|
||||
:class="
|
||||
cn(
|
||||
'flex aspect-square size-8 items-center justify-center overflow-hidden rounded-md text-base font-semibold text-white',
|
||||
$attrs.class as string
|
||||
)
|
||||
"
|
||||
:style="imageUrl ? undefined : { background: gradient, textShadow }"
|
||||
>
|
||||
{{ letter }}
|
||||
<img
|
||||
v-if="imageUrl"
|
||||
:src="imageUrl"
|
||||
alt=""
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<template v-else>{{ letter }}</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { workspaceName } = defineProps<{
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const { workspaceName, imageUrl } = defineProps<{
|
||||
workspaceName: string
|
||||
imageUrl?: string
|
||||
}>()
|
||||
|
||||
const textShadow = '0 1px 2px rgba(0, 0, 0, 0.2)'
|
||||
|
||||
const letter = computed(() => workspaceName?.charAt(0)?.toUpperCase() ?? '?')
|
||||
|
||||
const gradient = computed(() => {
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-132 max-w-full flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.autoReload.dialog.title') }}
|
||||
</h2>
|
||||
<button
|
||||
class="cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="onClose"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.autoReload.dialog.thresholdLabel') }}
|
||||
</label>
|
||||
<div :class="fieldClass">
|
||||
<i class="icon-[lucide--coins] size-4 shrink-0 text-credit" />
|
||||
<input
|
||||
v-model="thresholdModel"
|
||||
inputmode="numeric"
|
||||
class="w-full min-w-0 border-none bg-transparent text-sm text-base-foreground tabular-nums outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.autoReload.dialog.amountLabel') }}
|
||||
</label>
|
||||
<div
|
||||
:class="cn(fieldClass, reloadBelowMinimum && 'ring-1 ring-red-500')"
|
||||
>
|
||||
<i
|
||||
v-if="unit === 'credits'"
|
||||
class="icon-[lucide--coins] size-4 shrink-0 text-credit"
|
||||
/>
|
||||
<span v-else class="shrink-0 text-sm text-muted-foreground">$</span>
|
||||
<input
|
||||
v-model="reloadModel"
|
||||
inputmode="numeric"
|
||||
class="w-full min-w-0 border-none bg-transparent text-sm text-base-foreground tabular-nums outline-none"
|
||||
/>
|
||||
<span
|
||||
class="flex shrink-0 items-center gap-1 text-sm text-muted-foreground tabular-nums"
|
||||
>
|
||||
<template v-if="unit === 'credits'"
|
||||
>≈ {{ reloadCostLabel }}</template
|
||||
>
|
||||
<template v-else>
|
||||
≈
|
||||
<i class="icon-[lucide--coins] size-3.5 text-muted-foreground" />
|
||||
{{ reloadCreditsLabel }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="reloadError" class="m-0 text-xs text-red-500">
|
||||
{{ reloadError }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 border-t border-border-default p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-base-foreground">
|
||||
{{ $t('workspacePanel.autoReload.dialog.budgetToggleLabel') }}
|
||||
</span>
|
||||
<span class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{{
|
||||
budgetEnabled
|
||||
? $t('workspacePanel.autoReload.enabled')
|
||||
: $t('workspacePanel.autoReload.disabled')
|
||||
}}
|
||||
<Switch v-model="budgetEnabled" />
|
||||
</span>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.autoReload.dialog.budgetToggleHint') }}
|
||||
</p>
|
||||
<div :class="cn(fieldClass, !budgetEnabled && 'opacity-50')">
|
||||
<i
|
||||
v-if="unit === 'credits'"
|
||||
class="icon-[lucide--coins] size-4 shrink-0 text-credit"
|
||||
/>
|
||||
<span v-else class="shrink-0 text-sm text-muted-foreground">$</span>
|
||||
<input
|
||||
v-model="budgetModel"
|
||||
:disabled="!budgetEnabled"
|
||||
inputmode="numeric"
|
||||
:placeholder="budgetPlaceholder"
|
||||
class="w-full min-w-0 border-none bg-transparent text-sm text-base-foreground tabular-nums outline-none disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span
|
||||
v-if="budgetEnabled && budgetCents > 0"
|
||||
class="flex shrink-0 items-center gap-1 text-sm text-muted-foreground tabular-nums"
|
||||
>
|
||||
<template v-if="unit === 'credits'">≈ {{ budgetUsdLabel }}</template>
|
||||
<template v-else>
|
||||
≈
|
||||
<i class="icon-[lucide--coins] size-3.5 text-muted-foreground" />
|
||||
{{ budgetCreditsLabel }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="budgetEnabled && budgetCents > 0"
|
||||
class="m-0 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ allowsReloadsLabel }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-border-default p-4"
|
||||
>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
:model-value="unit"
|
||||
class="rounded-lg bg-secondary-background p-0.5"
|
||||
@update:model-value="onUnitChange"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
v-for="option in unitOptions"
|
||||
:key="option"
|
||||
:value="option"
|
||||
size="lg"
|
||||
>
|
||||
{{ $t(`workspacePanel.autoReload.dialog.${option}`) }}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<div class="flex items-center gap-4">
|
||||
<Button variant="muted-textonly" @click="onClose">
|
||||
{{ $t('workspacePanel.autoReload.dialog.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="!canUpdate"
|
||||
@click="onUpdate"
|
||||
>
|
||||
{{ $t('workspacePanel.autoReload.dialog.update') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
centsToCredits,
|
||||
creditsToCents,
|
||||
creditsToUsd,
|
||||
usdToCents,
|
||||
usdToCredits
|
||||
} from '@/base/credits/comfyCredits'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Switch from '@/components/ui/switch/Switch.vue'
|
||||
import ToggleGroup from '@/components/ui/toggle-group/ToggleGroup.vue'
|
||||
import ToggleGroupItem from '@/components/ui/toggle-group/ToggleGroupItem.vue'
|
||||
import { useAutoReload } from '@/platform/workspace/composables/useAutoReload'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { t, n: fmtNumber } = useI18n()
|
||||
const dialogStore = useDialogStore()
|
||||
const { config, save } = useAutoReload()
|
||||
|
||||
type Unit = 'credits' | 'usd'
|
||||
const unitOptions: Unit[] = ['credits', 'usd']
|
||||
const unit = ref<Unit>('credits')
|
||||
|
||||
// reka's single ToggleGroup can emit '' on re-click (deselect); ignore that so a
|
||||
// unit always stays selected.
|
||||
function onUnitChange(value: unknown) {
|
||||
if (value === 'credits' || value === 'usd') unit.value = value
|
||||
}
|
||||
|
||||
const thresholdCredits = ref(config.thresholdCredits || 1000)
|
||||
const reloadCredits = ref(config.reloadCredits || 5000)
|
||||
const budgetEnabled = ref(config.monthlyBudgetCents != null)
|
||||
const budgetCents = ref(config.monthlyBudgetCents ?? 0)
|
||||
|
||||
const fieldClass =
|
||||
'flex items-center gap-2 rounded-lg bg-secondary-background px-3 py-2.5'
|
||||
|
||||
const fmtInt = (value: number) => fmtNumber(value, { maximumFractionDigits: 0 })
|
||||
const fmtUsd = (cents: number) =>
|
||||
(cents / 100).toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0
|
||||
})
|
||||
const parseNum = (raw: string) => {
|
||||
const parsed = Number(raw.replace(/[^0-9.]/g, ''))
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
// A 0 value renders as an empty field (not "0") so backspacing clears it.
|
||||
const thresholdModel = computed({
|
||||
get: () =>
|
||||
thresholdCredits.value === 0 ? '' : fmtInt(thresholdCredits.value),
|
||||
set: (value) => (thresholdCredits.value = Math.round(parseNum(value)))
|
||||
})
|
||||
|
||||
const reloadModel = computed({
|
||||
get: () => {
|
||||
if (reloadCredits.value === 0) return ''
|
||||
return unit.value === 'credits'
|
||||
? fmtInt(reloadCredits.value)
|
||||
: fmtInt(creditsToUsd(reloadCredits.value))
|
||||
},
|
||||
set: (value) => {
|
||||
const parsed = parseNum(value)
|
||||
reloadCredits.value =
|
||||
unit.value === 'credits' ? Math.round(parsed) : usdToCredits(parsed)
|
||||
}
|
||||
})
|
||||
const reloadCostLabel = computed(() =>
|
||||
fmtUsd(creditsToCents(reloadCredits.value))
|
||||
)
|
||||
const reloadCreditsLabel = computed(() => fmtInt(reloadCredits.value))
|
||||
|
||||
const budgetModel = computed({
|
||||
get: () => {
|
||||
if (budgetCents.value === 0) return ''
|
||||
return unit.value === 'credits'
|
||||
? fmtInt(centsToCredits(budgetCents.value))
|
||||
: fmtInt(Math.round(budgetCents.value / 100))
|
||||
},
|
||||
set: (value) => {
|
||||
const parsed = parseNum(value)
|
||||
budgetCents.value =
|
||||
unit.value === 'credits'
|
||||
? creditsToCents(Math.round(parsed))
|
||||
: usdToCents(parsed)
|
||||
}
|
||||
})
|
||||
const budgetUsdLabel = computed(() => fmtUsd(budgetCents.value))
|
||||
const budgetCreditsLabel = computed(() =>
|
||||
fmtInt(centsToCredits(budgetCents.value))
|
||||
)
|
||||
const budgetPlaceholder = computed(() =>
|
||||
unit.value === 'credits'
|
||||
? t('workspacePanel.autoReload.dialog.budgetPlaceholderCredits')
|
||||
: t('workspacePanel.autoReload.dialog.budgetPlaceholderUsd')
|
||||
)
|
||||
|
||||
const allowsReloadsLabel = computed(() => {
|
||||
const reloads =
|
||||
reloadCredits.value > 0
|
||||
? Math.floor(centsToCredits(budgetCents.value) / reloadCredits.value)
|
||||
: 0
|
||||
return t('workspacePanel.autoReload.dialog.allowsReloads', reloads)
|
||||
})
|
||||
|
||||
// The reload amount must be worth at least $5 (its credit equivalent).
|
||||
const MIN_RELOAD_CENTS = 500
|
||||
const MIN_RELOAD_CREDITS = usdToCredits(5)
|
||||
|
||||
const reloadBelowMinimum = computed(
|
||||
() => reloadCredits.value > 0 && reloadCredits.value < MIN_RELOAD_CREDITS
|
||||
)
|
||||
const reloadError = computed(() => {
|
||||
if (!reloadBelowMinimum.value) return ''
|
||||
const amount =
|
||||
unit.value === 'credits'
|
||||
? fmtInt(MIN_RELOAD_CREDITS)
|
||||
: fmtUsd(MIN_RELOAD_CENTS)
|
||||
return t('workspacePanel.autoReload.dialog.minReload', { amount })
|
||||
})
|
||||
|
||||
const canUpdate = computed(
|
||||
() =>
|
||||
thresholdCredits.value > 0 &&
|
||||
reloadCredits.value >= MIN_RELOAD_CREDITS &&
|
||||
(!budgetEnabled.value || budgetCents.value > 0)
|
||||
)
|
||||
|
||||
function onClose() {
|
||||
dialogStore.closeDialog({ key: 'auto-reload' })
|
||||
}
|
||||
|
||||
function onUpdate() {
|
||||
if (!canUpdate.value) return
|
||||
save({
|
||||
thresholdCredits: thresholdCredits.value,
|
||||
reloadCredits: reloadCredits.value,
|
||||
monthlyBudgetCents:
|
||||
budgetEnabled.value && budgetCents.value > 0 ? budgetCents.value : null
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
</script>
|
||||
@@ -56,7 +56,7 @@ describe('ChangeMemberRoleDialogContent', () => {
|
||||
mockChangeMemberRole.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('shows promote copy and confirms with Make owner', async () => {
|
||||
it('shows promote copy and confirms with Make admin', async () => {
|
||||
const { user } = renderDialog('owner')
|
||||
|
||||
expect(
|
||||
|
||||
@@ -64,6 +64,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { onConfirm } = defineProps<{
|
||||
@@ -80,7 +81,11 @@ const workspaceName = ref('')
|
||||
const isValidName = computed(() => {
|
||||
const name = workspaceName.value.trim()
|
||||
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
|
||||
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
|
||||
return (
|
||||
name.length >= 1 &&
|
||||
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
|
||||
safeNameRegex.test(name)
|
||||
)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
|
||||
@@ -58,6 +58,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -70,7 +71,11 @@ const newWorkspaceName = ref(workspaceStore.workspaceName)
|
||||
const isValidName = computed(() => {
|
||||
const name = newWorkspaceName.value.trim()
|
||||
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
|
||||
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
|
||||
return (
|
||||
name.length >= 1 &&
|
||||
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
|
||||
safeNameRegex.test(name)
|
||||
)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-lg flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
class="flex w-132 max-w-full flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
@@ -35,7 +35,7 @@
|
||||
:value="email"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-full',
|
||||
'rounded-full bg-tertiary-background-hover',
|
||||
!EMAIL_REGEX.test(email) && 'bg-danger/20 text-danger'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 items-center justify-between border-b border-border-default px-4"
|
||||
>
|
||||
<h2 class="m-0 text-sm font-normal text-base-foreground">{{ title }}</h2>
|
||||
<button
|
||||
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="close"
|
||||
>
|
||||
<i class="pi pi-times size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<p class="m-0 text-sm text-muted-foreground">{{ message }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 p-4">
|
||||
<Button variant="muted-textonly" @click="close">
|
||||
{{ $t('g.close') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="requestMore">
|
||||
{{ $t('workspacePanel.requestMore') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { dialogKey, onRequestMore } = defineProps<{
|
||||
dialogKey: string
|
||||
title: string
|
||||
message: string
|
||||
onRequestMore: () => void
|
||||
}>()
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
function close() {
|
||||
dialogStore.closeDialog({ key: dialogKey })
|
||||
}
|
||||
|
||||
function requestMore() {
|
||||
onRequestMore()
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
@@ -232,7 +232,7 @@ describe('TeamWorkspacesDialogContent', () => {
|
||||
expect(findCreateButton(container)).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables create button for name exceeding 50 characters', async () => {
|
||||
it('disables create button for name exceeding the character limit', async () => {
|
||||
const { container, user } = mountComponent()
|
||||
const input = container.querySelector(
|
||||
'#workspace-name-input'
|
||||
|
||||
@@ -145,6 +145,7 @@ import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfil
|
||||
import { useWorkspaceSwitch } from '@/platform/workspace/composables/useWorkspaceSwitch'
|
||||
import { useWorkspaceTierLabel } from '@/platform/workspace/composables/useWorkspaceTierLabel'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const { onConfirm } = defineProps<{
|
||||
@@ -178,7 +179,11 @@ const tierLabels = computed(
|
||||
|
||||
const isValidName = computed(() => {
|
||||
const name = workspaceName.value.trim()
|
||||
return name.length >= 1 && name.length <= 50 && SAFE_NAME_REGEX.test(name)
|
||||
return (
|
||||
name.length >= 1 &&
|
||||
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
|
||||
SAFE_NAME_REGEX.test(name)
|
||||
)
|
||||
})
|
||||
|
||||
function onCancel() {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-4 rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-base-foreground">
|
||||
{{ $t('workspacePanel.autoReload.title') }}
|
||||
</span>
|
||||
<span class="max-w-md text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.autoReload.subtitle') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="isConfigured" class="flex shrink-0 items-center gap-3">
|
||||
<span class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{{ enabledLabel }}
|
||||
<Switch :model-value="isEnabled" @update:model-value="setEnabled" />
|
||||
</span>
|
||||
<Button variant="secondary" size="lg" @click="openConfig">
|
||||
{{ $t('workspacePanel.autoReload.edit') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty / not-set-up state — same one-column grid as the configured tile
|
||||
so both sit at the top tiles' width. -->
|
||||
<div v-if="!isConfigured" class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
class="flex flex-col gap-3 rounded-xl bg-modal-panel-background px-6 py-5"
|
||||
>
|
||||
<p class="m-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.autoReload.empty.body') }}
|
||||
</p>
|
||||
<Button variant="tertiary" size="lg" class="w-full" @click="openConfig">
|
||||
{{ $t('workspacePanel.autoReload.empty.cta') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configured tile — constrained to one column of the same grid the top
|
||||
tiles use, so it matches their width (empty second column left open). -->
|
||||
<div v-else class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col gap-4 rounded-xl bg-modal-panel-background px-6 py-5 transition-opacity',
|
||||
!isEnabled && 'opacity-50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.autoReload.tile.label') }}
|
||||
</span>
|
||||
<StatusBadge v-if="badge" :label="badge" severity="secondary" />
|
||||
</div>
|
||||
|
||||
<p
|
||||
:class="
|
||||
cn(
|
||||
'm-0 flex items-center gap-1.5 text-sm text-muted-foreground',
|
||||
isPaused && 'opacity-50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--coins] size-4 text-credit" />
|
||||
<span class="text-lg font-semibold text-base-foreground tabular-nums">
|
||||
{{ reloadCreditsLabel }}
|
||||
</span>
|
||||
{{ $t('workspacePanel.autoReload.tile.whenBelow') }}
|
||||
<span class="font-semibold text-base-foreground tabular-nums">
|
||||
{{ thresholdLabel }}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div v-if="hasBudget" class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">
|
||||
{{ $t('workspacePanel.autoReload.tile.monthlyBudget') }}
|
||||
</span>
|
||||
<span class="text-muted-foreground">{{ resetsLabel }}</span>
|
||||
</div>
|
||||
<ProgressBar :value="budgetUsedFraction" />
|
||||
<div class="flex items-start justify-between text-sm">
|
||||
<span class="text-muted-foreground tabular-nums">
|
||||
{{ spentLabel }}
|
||||
</span>
|
||||
<div class="flex flex-col items-end gap-0.5">
|
||||
<span class="font-medium text-base-foreground tabular-nums">
|
||||
{{ leftLabel }}
|
||||
</span>
|
||||
<span :class="cn('tabular-nums', reloadsLeftClass)">
|
||||
{{ reloadsLeftLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-interface-stroke/60 pt-3 text-sm text-muted-foreground"
|
||||
>
|
||||
<span class="tabular-nums">{{ recentReloadLabel }}</span>
|
||||
<span
|
||||
v-if="config.lastReload"
|
||||
class="flex items-center gap-1.5 tabular-nums"
|
||||
>
|
||||
<i class="icon-[lucide--coins] size-4 text-credit" />
|
||||
<span class="text-base-foreground">{{
|
||||
lastReloadCreditsLabel
|
||||
}}</span>
|
||||
<span>·</span>
|
||||
<span class="text-base-foreground">{{ lastReloadCostLabel }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { creditsToCents } from '@/base/credits/comfyCredits'
|
||||
import StatusBadge from '@/components/common/StatusBadge.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Switch from '@/components/ui/switch/Switch.vue'
|
||||
import ProgressBar from '@/platform/workspace/components/dialogs/settings/ProgressBar.vue'
|
||||
import { useAutoReload } from '@/platform/workspace/composables/useAutoReload'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { t, d, n: fmtNumber } = useI18n()
|
||||
|
||||
const {
|
||||
config,
|
||||
cycleResetDate,
|
||||
isConfigured,
|
||||
isEnabled,
|
||||
hasBudget,
|
||||
budgetLeftCents,
|
||||
budgetUsedFraction,
|
||||
reloadsLeft,
|
||||
isPaused,
|
||||
isWarning,
|
||||
setEnabled
|
||||
} = useAutoReload()
|
||||
|
||||
const { showAutoReloadDialog } = useDialogService()
|
||||
|
||||
function openConfig() {
|
||||
void showAutoReloadDialog()
|
||||
}
|
||||
|
||||
const fmtCredits = (value: number) =>
|
||||
fmtNumber(value, { maximumFractionDigits: 0 })
|
||||
const fmtUsd = (cents: number) =>
|
||||
(cents / 100).toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0
|
||||
})
|
||||
|
||||
const enabledLabel = computed(() =>
|
||||
isEnabled.value
|
||||
? t('workspacePanel.autoReload.enabled')
|
||||
: t('workspacePanel.autoReload.disabled')
|
||||
)
|
||||
|
||||
const badge = computed(() => {
|
||||
if (isPaused.value) return t('workspacePanel.autoReload.badge.paused')
|
||||
if (!isEnabled.value) return t('workspacePanel.autoReload.badge.off')
|
||||
return ''
|
||||
})
|
||||
|
||||
const reloadCreditsLabel = computed(() => fmtCredits(config.reloadCredits))
|
||||
const thresholdLabel = computed(() => fmtCredits(config.thresholdCredits))
|
||||
|
||||
const resetsLabel = computed(() =>
|
||||
t('workspacePanel.autoReload.tile.resets', {
|
||||
date: d(cycleResetDate, { month: 'short', day: 'numeric' })
|
||||
})
|
||||
)
|
||||
const spentLabel = computed(() =>
|
||||
t('workspacePanel.autoReload.tile.spent', {
|
||||
amount: fmtUsd(config.spentThisCycleCents)
|
||||
})
|
||||
)
|
||||
const leftLabel = computed(() =>
|
||||
t('workspacePanel.autoReload.tile.leftOfBudget', {
|
||||
left: fmtUsd(budgetLeftCents.value),
|
||||
budget: fmtUsd(config.monthlyBudgetCents ?? 0)
|
||||
})
|
||||
)
|
||||
const reloadsLeftLabel = computed(() =>
|
||||
t('workspacePanel.autoReload.tile.reloadsLeft', reloadsLeft.value ?? 0)
|
||||
)
|
||||
const reloadsLeftClass = computed(() =>
|
||||
isPaused.value
|
||||
? 'text-danger'
|
||||
: isWarning.value
|
||||
? 'text-credit'
|
||||
: 'text-muted-foreground'
|
||||
)
|
||||
|
||||
const recentReloadLabel = computed(() =>
|
||||
config.lastReload
|
||||
? t('workspacePanel.autoReload.tile.recentReload', {
|
||||
date: d(config.lastReload.date, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
})
|
||||
: t('workspacePanel.autoReload.tile.noRecentReload')
|
||||
)
|
||||
const lastReloadCreditsLabel = computed(() =>
|
||||
config.lastReload ? fmtCredits(config.lastReload.credits) : ''
|
||||
)
|
||||
const lastReloadCostLabel = computed(() =>
|
||||
config.lastReload ? fmtUsd(creditsToCents(config.lastReload.credits)) : ''
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="banner"
|
||||
class="flex items-center gap-2 rounded-2xl border border-interface-stroke/60 bg-base-background p-4"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--circle-alert] size-4 shrink-0',
|
||||
banner.kind === 'warning'
|
||||
? 'text-warning-background'
|
||||
: 'text-muted-foreground'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span class="text-sm text-base-foreground">{{ banner.title }}</span>
|
||||
</div>
|
||||
<p class="m-0 pl-6 text-sm text-muted-foreground">{{ banner.body }}</p>
|
||||
</div>
|
||||
<Button v-if="banner.showAction" variant="inverted" size="lg">
|
||||
{{ $t('workspacePanel.billingStatus.updatePayment') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { t, d } = useI18n()
|
||||
const { billingStatus, subscriptionStatus, renewalDate } = useBillingContext()
|
||||
const { permissions, isInPersonalWorkspace } = useWorkspaceUI()
|
||||
|
||||
const canManage = computed(() => permissions.value.canManageSubscription)
|
||||
|
||||
// A payment failure surfaces one banner across every workspace tab. Paused takes
|
||||
// priority over the grace-period warning; the warning is owner/admin-only since
|
||||
// members can't act on it (they see nothing until it actually pauses).
|
||||
const banner = computed(() => {
|
||||
if (isInPersonalWorkspace.value) return null
|
||||
|
||||
if (subscriptionStatus.value === 'paused') {
|
||||
return {
|
||||
kind: 'paused' as const,
|
||||
title: t('workspacePanel.billingStatus.paused.title'),
|
||||
body: canManage.value
|
||||
? t('workspacePanel.billingStatus.paused.body')
|
||||
: t('workspacePanel.billingStatus.paused.memberBody'),
|
||||
showAction: canManage.value
|
||||
}
|
||||
}
|
||||
|
||||
if (billingStatus.value === 'payment_failed' && canManage.value) {
|
||||
const raw = renewalDate.value
|
||||
return {
|
||||
kind: 'warning' as const,
|
||||
title: t('workspacePanel.billingStatus.warning.title'),
|
||||
body: t('workspacePanel.billingStatus.warning.body', {
|
||||
date: raw ? d(new Date(raw), { month: 'short', day: 'numeric' }) : ''
|
||||
}),
|
||||
showAction: true
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
</script>
|
||||
@@ -1,91 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
:data-testid="`member-row-${member.id}`"
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center rounded-lg p-2',
|
||||
isSingleSeatPlan ? 'grid-cols-1' : gridCols,
|
||||
striped && 'bg-secondary-background/50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<UserAvatar
|
||||
class="size-8"
|
||||
:photo-url="isCurrentUser ? photoUrl : undefined"
|
||||
:pt:icon:class="{ 'text-xl!': !isCurrentUser || !photoUrl }"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ member.name }}
|
||||
<span v-if="isCurrentUser" class="text-muted-foreground">
|
||||
({{ $t('g.you') }})
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ member.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="showRoleColumn && !isSingleSeatPlan"
|
||||
class="text-right text-sm text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
member.role === 'owner'
|
||||
? $t('workspaceSwitcher.roleOwner')
|
||||
: $t('workspaceSwitcher.roleMember')
|
||||
}}
|
||||
</span>
|
||||
<div
|
||||
v-if="canManageMembers && !isSingleSeatPlan"
|
||||
class="flex items-center justify-end"
|
||||
>
|
||||
<DropdownMenu
|
||||
v-if="!isCurrentUser && !isOriginalOwner"
|
||||
:entries="menuItems"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import UserAvatar from '@/components/common/UserAvatar.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
showRoleColumn = false,
|
||||
canManageMembers = false,
|
||||
isSingleSeatPlan = false,
|
||||
isOriginalOwner = false,
|
||||
striped = false,
|
||||
menuItems = []
|
||||
} = defineProps<{
|
||||
member: WorkspaceMember
|
||||
isCurrentUser: boolean
|
||||
photoUrl?: string
|
||||
gridCols: string
|
||||
showRoleColumn?: boolean
|
||||
canManageMembers?: boolean
|
||||
isSingleSeatPlan?: boolean
|
||||
isOriginalOwner?: boolean
|
||||
striped?: boolean
|
||||
menuItems?: MenuItem[]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<TableRow
|
||||
:data-testid="`member-row-${member.id}`"
|
||||
class="group hover:bg-transparent"
|
||||
>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-full"
|
||||
:style="{
|
||||
backgroundColor: userBadgeColor(member.name || member.email)
|
||||
}"
|
||||
>
|
||||
<span class="text-sm font-bold text-base-foreground">
|
||||
{{ initial }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ member.name }}
|
||||
<span v-if="isCurrentUser" class="text-muted-foreground">
|
||||
({{ $t('g.you') }})
|
||||
</span>
|
||||
</span>
|
||||
<span class="truncate text-sm text-muted-foreground">
|
||||
{{ member.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground">
|
||||
{{ $t(roleLabelKey(member.role, isOriginalOwner)) }}
|
||||
</TableCell>
|
||||
<TableCell v-if="canManageMembers" class="text-sm text-muted-foreground">
|
||||
{{ lastActivityLabel }}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
v-if="canManageMembers"
|
||||
class="text-right text-sm text-muted-foreground tabular-nums"
|
||||
>
|
||||
{{ creditsLabel }}
|
||||
</TableCell>
|
||||
<TableCell v-if="canManageMembers" class="text-right" @click.stop>
|
||||
<DropdownMenu
|
||||
v-if="showMenu"
|
||||
:entries="menuItems"
|
||||
:modal="false"
|
||||
content-class="min-w-44"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TableCell from '@/components/ui/table/TableCell.vue'
|
||||
import TableRow from '@/components/ui/table/TableRow.vue'
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
|
||||
import { roleLabelKey } from '@/platform/workspace/utils/roleLabels'
|
||||
import { formatRelativeTime } from '@/platform/workspace/utils/relativeTime'
|
||||
|
||||
const {
|
||||
member,
|
||||
isCurrentUser,
|
||||
canManageMembers = false,
|
||||
isOriginalOwner = false,
|
||||
menuItems = []
|
||||
} = defineProps<{
|
||||
member: WorkspaceMember
|
||||
isCurrentUser: boolean
|
||||
canManageMembers?: boolean
|
||||
isOriginalOwner?: boolean
|
||||
menuItems?: MenuItem[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const initial = computed(() =>
|
||||
(member.name || member.email).charAt(0).toUpperCase()
|
||||
)
|
||||
|
||||
// The creator and the current user can't be managed from their own row.
|
||||
const showMenu = computed(
|
||||
() => canManageMembers && !isCurrentUser && !isOriginalOwner
|
||||
)
|
||||
|
||||
const lastActivityLabel = computed(() => {
|
||||
if (!member.lastActivity) return '—'
|
||||
return formatRelativeTime(member.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 creditsLabel = computed(() =>
|
||||
(member.creditsUsedThisMonth ?? 0).toLocaleString()
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-2xl border border-border-subtle bg-base-background p-3"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<i
|
||||
class="icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
|
||||
/>
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('workspacePanel.members.outOfCredits.title') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="m-0 pl-6 text-sm text-muted-foreground">
|
||||
{{
|
||||
resetDate
|
||||
? $t('workspacePanel.members.outOfCredits.bodyWithReset', {
|
||||
date: resetDate
|
||||
})
|
||||
: $t('workspacePanel.members.outOfCredits.body')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Button variant="textonly" size="lg" @click="$emit('dismiss')">
|
||||
{{ $t('workspacePanel.members.outOfCredits.dismiss') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="$emit('add-credits')">
|
||||
{{ $t('workspacePanel.members.outOfCredits.addCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{ resetDate?: string | null }>()
|
||||
|
||||
defineEmits<{ dismiss: []; 'add-credits': [] }>()
|
||||
</script>
|
||||
@@ -1,8 +1,7 @@
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Slots } from 'vue'
|
||||
import { computed, h, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import MembersPanelContent from './MembersPanelContent.vue'
|
||||
@@ -18,6 +17,9 @@ const mockMemberMenuItems = vi.fn(() => [])
|
||||
const mockShowTeamPlans = vi.fn()
|
||||
const mockToggleSort = vi.fn()
|
||||
const mockHandleInviteMember = vi.fn()
|
||||
const mockDismissBanner = vi.fn()
|
||||
const mockHandleAddCredits = vi.fn()
|
||||
const mockFetchBalance = vi.fn()
|
||||
|
||||
const {
|
||||
mockMembers,
|
||||
@@ -27,13 +29,15 @@ const {
|
||||
mockFilteredPendingInvites,
|
||||
mockIsPersonalWorkspace,
|
||||
mockIsOnTeamPlan,
|
||||
mockHasMultipleMembers,
|
||||
mockShowSearch,
|
||||
mockShowViewTabs,
|
||||
mockShowInviteButton,
|
||||
mockIsInviteDisabled,
|
||||
mockActiveView,
|
||||
mockSearchQuery,
|
||||
mockSortField,
|
||||
mockSortDirection,
|
||||
mockShowOutOfCreditsBanner,
|
||||
mockPermissions,
|
||||
mockUiConfig
|
||||
} = vi.hoisted(() => {
|
||||
@@ -44,7 +48,6 @@ const {
|
||||
mockMembers: ref<WorkspaceMember[]>([]),
|
||||
mockPendingInvites: ref<PendingInvite[]>([]),
|
||||
mockOriginalOwnerId: ref<string | null>(null),
|
||||
mockHasMultipleMembers: ref(true),
|
||||
mockShowSearch: ref(true),
|
||||
mockShowViewTabs: ref(true),
|
||||
mockShowInviteButton: ref(true),
|
||||
@@ -55,28 +58,19 @@ const {
|
||||
mockIsOnTeamPlan: ref(true),
|
||||
mockActiveView: ref<'active' | 'pending'>('active'),
|
||||
mockSearchQuery: ref(''),
|
||||
mockSortField: ref('role'),
|
||||
mockSortDirection: ref('desc'),
|
||||
mockShowOutOfCreditsBanner: ref(false),
|
||||
mockPermissions: ref({
|
||||
canViewOtherMembers: true,
|
||||
canViewPendingInvites: true,
|
||||
canInviteMembers: true,
|
||||
canManageInvites: true,
|
||||
canManageMembers: true,
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: true,
|
||||
canTopUp: true
|
||||
canManageMembers: true
|
||||
}),
|
||||
mockUiConfig: ref({
|
||||
showMembersList: true,
|
||||
showPendingTab: true,
|
||||
showSearch: true,
|
||||
showRoleColumn: true,
|
||||
membersGridCols: 'grid-cols-[50%_40%_10%]',
|
||||
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
|
||||
headerGridCols: 'grid-cols-[50%_40%_10%]',
|
||||
showEditWorkspaceMenuItem: true,
|
||||
workspaceMenuAction: 'delete' as 'leave' | 'delete' | null,
|
||||
workspaceMenuDisabledTooltip: null as string | null
|
||||
showSearch: true
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -85,9 +79,12 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
|
||||
useMembersPanel: () => ({
|
||||
searchQuery: mockSearchQuery,
|
||||
activeView: mockActiveView,
|
||||
maxSeats: computed(() => 20),
|
||||
sortField: mockSortField,
|
||||
sortDirection: mockSortDirection,
|
||||
maxSeats: computed(() => 50),
|
||||
memberCount: computed(() => mockMembers.value.length),
|
||||
isOnTeamPlan: mockIsOnTeamPlan,
|
||||
hasMultipleMembers: mockHasMultipleMembers,
|
||||
hasLapsedTeamPlan: computed(() => false),
|
||||
showSearch: mockShowSearch,
|
||||
showViewTabs: mockShowViewTabs,
|
||||
showInviteButton: mockShowInviteButton,
|
||||
@@ -104,7 +101,6 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
|
||||
})),
|
||||
filteredMembers: mockFilteredMembers,
|
||||
filteredPendingInvites: mockFilteredPendingInvites,
|
||||
memberMenuItems: mockMemberMenuItems,
|
||||
memberMenus: computed(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -112,28 +108,25 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
|
||||
)
|
||||
),
|
||||
isPersonalWorkspace: mockIsPersonalWorkspace,
|
||||
members: mockMembers,
|
||||
pendingInvites: mockPendingInvites,
|
||||
permissions: mockPermissions,
|
||||
uiConfig: mockUiConfig,
|
||||
userPhotoUrl: ref(null),
|
||||
showOutOfCreditsBanner: mockShowOutOfCreditsBanner,
|
||||
creditResetDate: computed(() => 'Feb 20'),
|
||||
dismissOutOfCreditsBanner: mockDismissBanner,
|
||||
handleAddCredits: mockHandleAddCredits,
|
||||
fetchBalance: mockFetchBalance,
|
||||
isCurrentUser: (m: WorkspaceMember) =>
|
||||
m.email.toLowerCase() === 'owner@example.com',
|
||||
isOriginalOwner: (m: WorkspaceMember) => m.id === mockOriginalOwnerId.value,
|
||||
toggleSort: mockToggleSort,
|
||||
showTeamPlans: mockShowTeamPlans,
|
||||
handleResendInvite: mockHandleResendInvite,
|
||||
handleRevokeInvite: mockHandleRevokeInvite,
|
||||
handleRemoveMember: vi.fn(),
|
||||
handleChangeRole: vi.fn()
|
||||
handleRevokeInvite: mockHandleRevokeInvite
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/components/button/MoreButton.vue', () => ({
|
||||
default: (_: unknown, { slots }: { slots: Slots }) =>
|
||||
h('div', slots.default?.({ close: () => {} }))
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -157,6 +150,15 @@ const SearchInputStub = {
|
||||
emits: ['update:modelValue']
|
||||
}
|
||||
|
||||
// Render the trigger slot (carries the g.moreOptions button) plus each entry as
|
||||
// a flat button, so menu items are assertable without opening a real overlay.
|
||||
const DropdownMenuStub = {
|
||||
name: 'DropdownMenu',
|
||||
props: ['entries', 'modal', 'contentClass'],
|
||||
template:
|
||||
'<div><slot name="button" /><button v-for="e in (entries || [])" :key="e.label" :aria-label="e.label" @click="e.command && e.command()">{{ e.label }}</button></div>'
|
||||
}
|
||||
|
||||
function renderComponent() {
|
||||
return render(MembersPanelContent, {
|
||||
global: {
|
||||
@@ -164,8 +166,8 @@ function renderComponent() {
|
||||
stubs: {
|
||||
Button: ButtonStub,
|
||||
SearchInput: SearchInputStub,
|
||||
UserAvatar: true,
|
||||
WorkspaceMenuButton: true
|
||||
DropdownMenu: DropdownMenuStub,
|
||||
UserAvatar: true
|
||||
},
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
@@ -207,35 +209,23 @@ describe('MembersPanelContent', () => {
|
||||
mockFilteredPendingInvites.value = []
|
||||
mockIsPersonalWorkspace.value = false
|
||||
mockIsOnTeamPlan.value = true
|
||||
mockHasMultipleMembers.value = true
|
||||
mockShowSearch.value = true
|
||||
mockShowViewTabs.value = true
|
||||
mockShowInviteButton.value = true
|
||||
mockIsInviteDisabled.value = false
|
||||
mockActiveView.value = 'active'
|
||||
mockSearchQuery.value = ''
|
||||
mockShowOutOfCreditsBanner.value = false
|
||||
mockPermissions.value = {
|
||||
canViewOtherMembers: true,
|
||||
canViewPendingInvites: true,
|
||||
canInviteMembers: true,
|
||||
canManageInvites: true,
|
||||
canManageMembers: true,
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: true,
|
||||
canTopUp: true
|
||||
canManageMembers: true
|
||||
}
|
||||
mockUiConfig.value = {
|
||||
showMembersList: true,
|
||||
showPendingTab: true,
|
||||
showSearch: true,
|
||||
showRoleColumn: true,
|
||||
membersGridCols: 'grid-cols-[50%_40%_10%]',
|
||||
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
|
||||
headerGridCols: 'grid-cols-[50%_40%_10%]',
|
||||
showEditWorkspaceMenuItem: true,
|
||||
workspaceMenuAction: 'delete',
|
||||
workspaceMenuDisabledTooltip: null
|
||||
showSearch: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -243,13 +233,9 @@ describe('MembersPanelContent', () => {
|
||||
beforeEach(() => {
|
||||
mockIsPersonalWorkspace.value = true
|
||||
mockIsOnTeamPlan.value = false
|
||||
mockHasMultipleMembers.value = false
|
||||
mockShowSearch.value = false
|
||||
mockShowViewTabs.value = false
|
||||
mockIsInviteDisabled.value = true
|
||||
mockUiConfig.value.showMembersList = false
|
||||
mockUiConfig.value.showSearch = false
|
||||
mockUiConfig.value.showPendingTab = false
|
||||
})
|
||||
|
||||
it('shows the upsell banner below the members card', () => {
|
||||
@@ -275,7 +261,7 @@ describe('MembersPanelContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('team workspace - member list', () => {
|
||||
describe('team workspace - member table', () => {
|
||||
it('shows the Role column header and member roles', () => {
|
||||
mockFilteredMembers.value = [
|
||||
createMember({ role: 'owner', email: 'boss@test.com' }),
|
||||
@@ -285,18 +271,47 @@ describe('MembersPanelContent', () => {
|
||||
expect(
|
||||
screen.getByText('workspacePanel.members.columns.role')
|
||||
).toBeTruthy()
|
||||
expect(screen.getByText('workspaceSwitcher.roleOwner')).toBeTruthy()
|
||||
expect(screen.getByText('workspaceSwitcher.roleAdmin')).toBeTruthy()
|
||||
expect(screen.getByText('workspaceSwitcher.roleMember')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the Last activity and Credits columns', () => {
|
||||
mockFilteredMembers.value = [createMember()]
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.members.columns.lastActivity')
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.members.columns.creditsUsed')
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the monthly credits for a member', () => {
|
||||
mockFilteredMembers.value = [createMember({ creditsUsedThisMonth: 6532 })]
|
||||
renderComponent()
|
||||
expect(screen.getByText('6,532')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('labels the original owner as Owner and other owners as Admin', () => {
|
||||
mockOriginalOwnerId.value = 'creator-1'
|
||||
mockFilteredMembers.value = [
|
||||
createMember({
|
||||
id: 'creator-1',
|
||||
email: 'creator@test.com',
|
||||
role: 'owner',
|
||||
isOriginalOwner: true
|
||||
}),
|
||||
createMember({ id: '2', email: 'admin@test.com', role: 'owner' })
|
||||
]
|
||||
renderComponent()
|
||||
expect(screen.getByText('workspaceSwitcher.roleOwner')).toBeTruthy()
|
||||
expect(screen.getByText('workspaceSwitcher.roleAdmin')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders filtered members', () => {
|
||||
mockFilteredMembers.value = [
|
||||
createMember({ name: 'Alice', email: 'alice@test.com' }),
|
||||
createMember({
|
||||
id: '2',
|
||||
name: 'Bob',
|
||||
email: 'bob@test.com'
|
||||
})
|
||||
createMember({ id: '2', name: 'Bob', email: 'bob@test.com' })
|
||||
]
|
||||
renderComponent()
|
||||
expect(screen.getByText('Alice')).toBeTruthy()
|
||||
@@ -347,6 +362,45 @@ describe('MembersPanelContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('out-of-credits banner', () => {
|
||||
it('renders the banner when the workspace is out of credits', () => {
|
||||
mockShowOutOfCreditsBanner.value = true
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.members.outOfCredits.title')
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('is hidden when the workspace has credits', () => {
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByText('workspacePanel.members.outOfCredits.title')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('dismisses the banner from the Dismiss action', async () => {
|
||||
mockShowOutOfCreditsBanner.value = true
|
||||
renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'workspacePanel.members.outOfCredits.dismiss'
|
||||
})
|
||||
)
|
||||
expect(mockDismissBanner).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the add-credits flow from the banner action', async () => {
|
||||
mockShowOutOfCreditsBanner.value = true
|
||||
renderComponent()
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'workspacePanel.members.outOfCredits.addCredits'
|
||||
})
|
||||
)
|
||||
expect(mockHandleAddCredits).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pending invites tab', () => {
|
||||
it('shows pending tab button when configured', () => {
|
||||
mockPendingInvites.value = [createInvite()]
|
||||
@@ -388,34 +442,20 @@ describe('MembersPanelContent', () => {
|
||||
describe('member role', () => {
|
||||
beforeEach(() => {
|
||||
mockPermissions.value = {
|
||||
canViewOtherMembers: true,
|
||||
canViewPendingInvites: false,
|
||||
canViewPendingInvites: true,
|
||||
canInviteMembers: false,
|
||||
canManageInvites: false,
|
||||
canManageMembers: false,
|
||||
canLeaveWorkspace: true,
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: false,
|
||||
canTopUp: false
|
||||
canManageMembers: false
|
||||
}
|
||||
mockUiConfig.value.showPendingTab = false
|
||||
mockUiConfig.value.showPendingTab = true
|
||||
})
|
||||
|
||||
it('hides the pending tab button', () => {
|
||||
it('shows the pending tab button (view-only)', () => {
|
||||
mockPendingInvites.value = [createInvite()]
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByText(/workspacePanel\.members\.tabs\.pendingCount/)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show the pending invites header', () => {
|
||||
mockActiveView.value = 'pending'
|
||||
mockPendingInvites.value = [createInvite()]
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByText(/workspacePanel\.members\.pendingInvitesCount/)
|
||||
).toBeNull()
|
||||
screen.getByText(/workspacePanel\.members\.tabs\.pendingCount/)
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows no action menus on member rows', () => {
|
||||
@@ -451,15 +491,6 @@ describe('MembersPanelContent', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('opens subscription dialog on upgrade click', async () => {
|
||||
renderComponent()
|
||||
const upgradeBtn = screen.getByRole('button', {
|
||||
name: /workspacePanel\.members\.upgradeToTeam/
|
||||
})
|
||||
await userEvent.click(upgradeBtn)
|
||||
expect(mockShowTeamPlans).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides search input', () => {
|
||||
renderComponent()
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
@@ -472,17 +503,15 @@ describe('MembersPanelContent', () => {
|
||||
})
|
||||
|
||||
describe('contact us footer', () => {
|
||||
it('opens discord in a new tab for team workspaces on a team plan', async () => {
|
||||
it('opens the team-plan request form in a new tab for team workspaces on a team plan', async () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByText('workspacePanel.members.needMoreMembers')
|
||||
).toBeTruthy()
|
||||
expect(screen.getByText(/needMoreMembers/)).toBeTruthy()
|
||||
await userEvent.click(
|
||||
screen.getByText('workspacePanel.members.contactUs')
|
||||
)
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://www.comfy.org/discord',
|
||||
'https://comfy-org.portal.usepylon.com/forms/team-plan-requests',
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
@@ -496,16 +525,12 @@ describe('MembersPanelContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('member count display', () => {
|
||||
it('shows member count header for team workspace', () => {
|
||||
mockFilteredMembers.value = [
|
||||
createMember({ id: '1' }),
|
||||
createMember({ id: '2' })
|
||||
]
|
||||
mockMembers.value = mockFilteredMembers.value
|
||||
describe('member count tab', () => {
|
||||
it('shows the members count tab for team workspace', () => {
|
||||
mockMembers.value = [createMember({ id: '1' }), createMember({ id: '2' })]
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByText(/workspacePanel\.members\.membersCount/)
|
||||
screen.getByText(/workspacePanel\.members\.tabs\.membersCount/)
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -540,10 +565,7 @@ describe('MembersPanelContent', () => {
|
||||
mockShowViewTabs.value = false
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.queryByText('workspacePanel.members.tabs.active')
|
||||
).toBeNull()
|
||||
expect(
|
||||
screen.queryByText('workspacePanel.members.columns.role')
|
||||
screen.queryByText(/workspacePanel\.members\.tabs\.pendingCount/)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,209 +1,212 @@
|
||||
<template>
|
||||
<div class="grow overflow-auto pt-6">
|
||||
<div
|
||||
class="border-inter flex size-full flex-col gap-2 rounded-2xl border border-interface-stroke p-6"
|
||||
>
|
||||
<!-- Section Header -->
|
||||
<div class="flex w-full items-center gap-9">
|
||||
<div class="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span class="text-base font-semibold text-base-foreground">
|
||||
<template v-if="activeView === 'active'">
|
||||
<template v-if="isOnTeamPlan && !isPersonalWorkspace">
|
||||
{{
|
||||
$t('workspacePanel.members.membersCount', {
|
||||
count: members.length,
|
||||
maxSeats: maxSeats
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('workspacePanel.members.header') }}
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="permissions.canViewPendingInvites">
|
||||
{{
|
||||
$t(
|
||||
'workspacePanel.members.pendingInvitesCount',
|
||||
pendingInvites.length
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<SearchInput
|
||||
v-if="showSearch"
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('workspacePanel.members.searchPlaceholder')"
|
||||
size="lg"
|
||||
class="w-64"
|
||||
/>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<!-- Header: tabs (left) + search / invite (right), above the card -->
|
||||
<div class="flex w-full items-center gap-9">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<template v-if="showViewTabs">
|
||||
<Button
|
||||
v-if="showInviteButton"
|
||||
v-tooltip="
|
||||
inviteTooltip
|
||||
? { value: inviteTooltip, showDelay: 0 }
|
||||
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
|
||||
"
|
||||
variant="secondary"
|
||||
:variant="activeView === 'active' ? 'secondary' : 'muted-textonly'"
|
||||
size="lg"
|
||||
:disabled="isInviteDisabled"
|
||||
:aria-label="$t('workspacePanel.inviteMember')"
|
||||
@click="handleInviteMember"
|
||||
@click="activeView = 'active'"
|
||||
>
|
||||
{{ $t('workspacePanel.invite') }}
|
||||
<i class="pi pi-plus text-sm" />
|
||||
{{ $t('workspacePanel.members.tabs.membersCount', memberCount) }}
|
||||
</Button>
|
||||
<WorkspaceMenuButton v-if="permissions.canAccessWorkspaceMenu" />
|
||||
</div>
|
||||
<Button
|
||||
v-if="uiConfig.showPendingTab"
|
||||
:variant="activeView === 'pending' ? 'secondary' : 'muted-textonly'"
|
||||
size="lg"
|
||||
@click="activeView = 'pending'"
|
||||
>
|
||||
{{
|
||||
pendingInvites.length > 0
|
||||
? $t(
|
||||
'workspacePanel.members.tabs.pendingCount',
|
||||
pendingInvites.length
|
||||
)
|
||||
: $t('workspacePanel.members.tabs.pending')
|
||||
}}
|
||||
</Button>
|
||||
</template>
|
||||
<span v-else class="text-base font-normal text-base-foreground">
|
||||
{{ $t('workspacePanel.members.tabs.membersCount', memberCount) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Members Content -->
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<!-- Table Header with Tab Buttons and Column Headers -->
|
||||
<div
|
||||
v-if="uiConfig.showMembersList && showViewTabs"
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center py-2',
|
||||
activeView === 'pending'
|
||||
? uiConfig.pendingGridCols
|
||||
: uiConfig.headerGridCols
|
||||
)
|
||||
<div class="flex items-center gap-2">
|
||||
<SearchInput
|
||||
v-if="showSearch"
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('workspacePanel.members.searchPlaceholder')"
|
||||
size="lg"
|
||||
class="w-64"
|
||||
/>
|
||||
<Button
|
||||
v-if="showInviteButton"
|
||||
v-tooltip="
|
||||
inviteTooltip
|
||||
? { value: inviteTooltip, showDelay: 0 }
|
||||
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
|
||||
"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
:disabled="isInviteDisabled"
|
||||
:aria-label="$t('workspacePanel.inviteMember')"
|
||||
@click="handleInviteMember"
|
||||
>
|
||||
<!-- Tab buttons in first column -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:variant="
|
||||
activeView === 'active' ? 'secondary' : 'muted-textonly'
|
||||
"
|
||||
size="md"
|
||||
@click="activeView = 'active'"
|
||||
{{ $t('workspacePanel.invite') }}
|
||||
<i class="icon-[lucide--plus] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BillingStatusBanner />
|
||||
|
||||
<MembersOutOfCreditsBanner
|
||||
v-if="showOutOfCreditsBanner && !isSubscriptionPaused"
|
||||
:reset-date="creditResetDate"
|
||||
@dismiss="dismissOutOfCreditsBanner"
|
||||
@add-credits="handleAddCredits"
|
||||
/>
|
||||
|
||||
<!-- Card: fills height, table scrolls inside -->
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-interface-stroke/60"
|
||||
>
|
||||
<Table v-if="activeView === 'active'" 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>
|
||||
<button :class="sortHeaderClass" @click="toggleSort('email')">
|
||||
{{ $t('workspacePanel.members.columns.email') }}
|
||||
<i :class="sortIcon('email')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
:class="permissions.canManageMembers ? 'w-40' : undefined"
|
||||
>
|
||||
{{ $t('workspacePanel.members.tabs.active') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="uiConfig.showPendingTab"
|
||||
:variant="
|
||||
activeView === 'pending' ? 'secondary' : 'muted-textonly'
|
||||
"
|
||||
size="md"
|
||||
@click="activeView = 'pending'"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
'workspacePanel.members.tabs.pendingCount',
|
||||
pendingInvites.length
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Date column headers -->
|
||||
<template v-if="activeView === 'pending'">
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
@click="toggleSort('inviteDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.inviteDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-start"
|
||||
@click="toggleSort('expiryDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.expiryDate') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<div />
|
||||
</template>
|
||||
<button :class="sortHeaderClass" @click="toggleSort('role')">
|
||||
{{ $t('workspacePanel.members.columns.role') }}
|
||||
<i :class="sortIcon('role')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead v-if="permissions.canManageMembers" class="w-40">
|
||||
<button
|
||||
:class="sortHeaderClass"
|
||||
@click="toggleSort('lastActivity')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.lastActivity') }}
|
||||
<i :class="sortIcon('lastActivity')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead v-if="permissions.canManageMembers" class="w-64">
|
||||
<button
|
||||
:class="cn(sortHeaderClass, 'ml-auto')"
|
||||
@click="toggleSort('credits')"
|
||||
>
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{ $t('workspacePanel.members.columns.creditsUsed') }}
|
||||
<i :class="sortIcon('credits')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead v-if="permissions.canManageMembers" class="w-12" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<MemberTableRow
|
||||
v-if="isPersonalWorkspace"
|
||||
:member="personalWorkspaceMember"
|
||||
:is-current-user="true"
|
||||
/>
|
||||
<template v-else>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="justify-end"
|
||||
@click="toggleSort('role')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.role') }}
|
||||
<i class="icon-[lucide--chevrons-up-down] size-4" />
|
||||
</Button>
|
||||
<!-- Empty cell for action column header (OWNER only) -->
|
||||
<div v-if="permissions.canManageMembers" />
|
||||
<MemberTableRow
|
||||
v-for="member in filteredMembers"
|
||||
:key="member.id"
|
||||
:member="member"
|
||||
:is-current-user="isCurrentUser(member)"
|
||||
:can-manage-members="permissions.canManageMembers"
|
||||
:is-original-owner="isOriginalOwner(member)"
|
||||
:menu-items="memberMenus.get(member.id)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<!-- Members List -->
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<!-- Active Members -->
|
||||
<template v-if="activeView === 'active'">
|
||||
<!-- Personal Workspace: show only current user -->
|
||||
<template v-if="isPersonalWorkspace">
|
||||
<MemberListItem
|
||||
:member="personalWorkspaceMember"
|
||||
:is-current-user="true"
|
||||
:photo-url="userPhotoUrl ?? undefined"
|
||||
:grid-cols="uiConfig.membersGridCols"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Team Workspace: sorted list -->
|
||||
<template v-else>
|
||||
<MemberListItem
|
||||
v-for="(member, index) in filteredMembers"
|
||||
:key="member.id"
|
||||
:member="member"
|
||||
:is-current-user="isCurrentUser(member)"
|
||||
:photo-url="
|
||||
isCurrentUser(member)
|
||||
? (userPhotoUrl ?? undefined)
|
||||
: undefined
|
||||
"
|
||||
:grid-cols="uiConfig.membersGridCols"
|
||||
:show-role-column="
|
||||
uiConfig.showRoleColumn && hasMultipleMembers
|
||||
"
|
||||
:can-manage-members="permissions.canManageMembers"
|
||||
:is-single-seat-plan="!isOnTeamPlan"
|
||||
:is-original-owner="isOriginalOwner(member)"
|
||||
:striped="index % 2 === 1"
|
||||
:menu-items="memberMenus.get(member.id)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Pending Invites -->
|
||||
<PendingInvitesList
|
||||
v-if="activeView === 'pending'"
|
||||
:invites="filteredPendingInvites"
|
||||
:grid-cols="uiConfig.pendingGridCols"
|
||||
<Table v-else 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>
|
||||
<span :class="sortHeaderClass">
|
||||
{{ $t('workspacePanel.members.columns.email') }}
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead class="w-40">
|
||||
<button
|
||||
:class="sortHeaderClass"
|
||||
@click="toggleSort('inviteDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.inviteDate') }}
|
||||
<i :class="sortIcon('inviteDate')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-40">
|
||||
<button
|
||||
:class="sortHeaderClass"
|
||||
@click="toggleSort('expiryDate')"
|
||||
>
|
||||
{{ $t('workspacePanel.members.columns.expiryDate') }}
|
||||
<i :class="sortIcon('expiryDate')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead v-if="permissions.canManageInvites" class="w-12" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<PendingInviteRow
|
||||
v-for="invite in filteredPendingInvites"
|
||||
:key="invite.id"
|
||||
:invite="invite"
|
||||
:can-manage="permissions.canManageInvites"
|
||||
@resend="handleResendInvite"
|
||||
@revoke="handleRevokeInvite"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TableRow
|
||||
v-if="filteredPendingInvites.length === 0"
|
||||
class="hover:bg-transparent"
|
||||
>
|
||||
<TableCell
|
||||
:colspan="permissions.canManageInvites ? 4 : 3"
|
||||
class="py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.members.noInvites') }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<!-- Upsell Banner -->
|
||||
|
||||
<MemberUpsellBanner
|
||||
v-if="!isOnTeamPlan"
|
||||
:reactivate="hasLapsedTeamPlan"
|
||||
@show-plans="showTeamPlans()"
|
||||
/>
|
||||
<!-- Need More Members Footer -->
|
||||
<div
|
||||
v-if="isOnTeamPlan && !isPersonalWorkspace"
|
||||
class="flex items-center pt-2"
|
||||
class="flex h-8 items-center"
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.members.needMoreMembers') }}
|
||||
{{ membersUsageLabel }}
|
||||
<template v-if="permissions.canInviteMembers">
|
||||
{{ $t('workspacePanel.members.needMoreMembers') }}
|
||||
</template>
|
||||
</p>
|
||||
<Button
|
||||
v-if="permissions.canInviteMembers"
|
||||
variant="muted-textonly"
|
||||
size="sm"
|
||||
class="text-base-foreground"
|
||||
size="md"
|
||||
class="text-sm text-base-foreground"
|
||||
@click="handleContactUs"
|
||||
>
|
||||
{{ $t('workspacePanel.members.contactUs') }}
|
||||
@@ -215,21 +218,33 @@
|
||||
<script setup lang="ts">
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Button from '@/components/ui/button/Button.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 { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import MemberListItem from '@/platform/workspace/components/dialogs/settings/MemberListItem.vue'
|
||||
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
|
||||
import MemberTableRow from '@/platform/workspace/components/dialogs/settings/MemberTableRow.vue'
|
||||
import MemberUpsellBanner from '@/platform/workspace/components/dialogs/settings/MemberUpsellBanner.vue'
|
||||
import PendingInvitesList from '@/platform/workspace/components/dialogs/settings/PendingInvitesList.vue'
|
||||
import WorkspaceMenuButton from '@/platform/workspace/components/dialogs/settings/WorkspaceMenuButton.vue'
|
||||
import MembersOutOfCreditsBanner from '@/platform/workspace/components/dialogs/settings/MembersOutOfCreditsBanner.vue'
|
||||
import PendingInviteRow from '@/platform/workspace/components/dialogs/settings/PendingInviteRow.vue'
|
||||
import { useMembersPanel } from '@/platform/workspace/composables/useMembersPanel'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const {
|
||||
searchQuery,
|
||||
activeView,
|
||||
sortField,
|
||||
sortDirection,
|
||||
maxSeats,
|
||||
memberCount,
|
||||
isOnTeamPlan,
|
||||
hasLapsedTeamPlan,
|
||||
hasMultipleMembers,
|
||||
showSearch,
|
||||
showViewTabs,
|
||||
showInviteButton,
|
||||
@@ -241,11 +256,14 @@ const {
|
||||
filteredPendingInvites,
|
||||
memberMenus,
|
||||
isPersonalWorkspace,
|
||||
members,
|
||||
pendingInvites,
|
||||
permissions,
|
||||
uiConfig,
|
||||
userPhotoUrl,
|
||||
showOutOfCreditsBanner,
|
||||
creditResetDate,
|
||||
dismissOutOfCreditsBanner,
|
||||
handleAddCredits,
|
||||
fetchBalance,
|
||||
isCurrentUser,
|
||||
isOriginalOwner,
|
||||
toggleSort,
|
||||
@@ -255,8 +273,40 @@ const {
|
||||
} = useMembersPanel()
|
||||
|
||||
const { staticUrls } = useExternalLink()
|
||||
const { t } = useI18n()
|
||||
const { subscriptionStatus } = useBillingContext()
|
||||
|
||||
// When paused, the workspace-wide "Subscription paused" banner already explains
|
||||
// the zero balance, so suppress the redundant out-of-credits banner here.
|
||||
const isSubscriptionPaused = computed(
|
||||
() => subscriptionStatus.value === 'paused'
|
||||
)
|
||||
|
||||
// Owners get "Need more members?" after the count, where the period reads as a
|
||||
// separator; members see just the count, so drop the trailing period.
|
||||
const membersUsageLabel = computed(() => {
|
||||
const label = t('workspacePanel.members.membersUsage', {
|
||||
count: memberCount.value,
|
||||
max: maxSeats.value
|
||||
})
|
||||
return permissions.value.canInviteMembers ? label : label.replace(/\.$/, '')
|
||||
})
|
||||
|
||||
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: string) {
|
||||
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 handleContactUs() {
|
||||
window.open(staticUrls.discord, '_blank', 'noopener,noreferrer')
|
||||
window.open(staticUrls.teamPlanRequests, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void fetchBalance()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<span
|
||||
class="flex size-5 shrink-0 items-center justify-center rounded-full bg-secondary-background-hover"
|
||||
>
|
||||
<i :class="cn(getProviderIcon(partner), 'size-3')" :style="iconStyle" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getProviderBorderStyle, getProviderIcon } from '@/utils/categoryUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { partner } = defineProps<{ partner: string }>()
|
||||
|
||||
// Monotone provider glyphs (Anthropic, BFL, …) render in currentColor, so tint
|
||||
// them with the brand color. Multi-color brands (ByteDance, Kling, Gemini, …)
|
||||
// ship full-color icons — identified by a gradient brand color — and must be
|
||||
// left untouched or the tint replaces their artwork.
|
||||
const iconStyle = computed(() => {
|
||||
const style = getProviderBorderStyle(partner)
|
||||
return style.includes('gradient') ? undefined : { color: style }
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="relative flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div class="flex w-full items-center gap-9">
|
||||
<span class="min-w-0 flex-1 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.partnerNodes.description') }}
|
||||
</span>
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('workspacePanel.partnerNodes.searchPlaceholder')"
|
||||
size="lg"
|
||||
class="w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BillingStatusBanner />
|
||||
|
||||
<div
|
||||
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 scrollbar-gutter-stable 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-6">
|
||||
<Checkbox
|
||||
:model-value="allFilteredSelected"
|
||||
:aria-label="$t('workspacePanel.partnerNodes.selectAll')"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<button :class="sortHeaderClass" @click="toggleSort('name')">
|
||||
{{ $t('workspacePanel.partnerNodes.columns.name') }}
|
||||
<i :class="sortIcon('name')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-40">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('partner')">
|
||||
{{ $t('workspacePanel.partnerNodes.columns.partner') }}
|
||||
<i :class="sortIcon('partner')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-40">
|
||||
<button
|
||||
:class="sortHeaderClass"
|
||||
@click="toggleSort('lastModified')"
|
||||
>
|
||||
{{ $t('workspacePanel.partnerNodes.columns.lastModified') }}
|
||||
<i :class="sortIcon('lastModified')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-14" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="node in filteredNodes"
|
||||
:key="node.id"
|
||||
:data-state="selectedIds.has(node.id) ? 'selected' : undefined"
|
||||
class="group cursor-pointer hover:bg-transparent data-[state=selected]:bg-transparent [&:hover>td]:bg-secondary-background/50 [&>td]:transition-colors [&>td:first-child]:rounded-l [&>td:last-child]:rounded-r [&[data-state=selected]>td]:bg-secondary-background/50"
|
||||
@click="toggleSelection(node.id)"
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
:model-value="selectedIds.has(node.id)"
|
||||
:aria-label="node.name"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none',
|
||||
!hasSelection &&
|
||||
'opacity-0 transition-opacity group-hover:opacity-100'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
<span :class="cn(!node.enabled && 'opacity-30')">
|
||||
{{ node.name }}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
<div
|
||||
:class="
|
||||
cn('flex items-center gap-2', !node.enabled && 'opacity-30')
|
||||
"
|
||||
>
|
||||
<PartnerBadge :partner="node.partner" />
|
||||
<span>{{ node.partner }}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
{{ formatLastModified(node.last_modified) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right" @click.stop>
|
||||
<Switch
|
||||
:model-value="node.enabled"
|
||||
@update:model-value="(v: boolean) => setEnabled(node, v)"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow
|
||||
v-if="filteredNodes.length === 0"
|
||||
class="hover:bg-transparent"
|
||||
>
|
||||
<TableCell
|
||||
:colspan="5"
|
||||
class="py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.partnerNodes.empty') }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Auto-enable default: outside the card, pinned to the panel bottom. pr-6
|
||||
lines the toggle up with the in-table row toggles (table px-4 + cell px-2);
|
||||
the reserved scrollbar gutter mirrors the table's so the two stay aligned
|
||||
whether or not the list is scrolling. -->
|
||||
<div
|
||||
class="flex h-8 scrollbar-gutter-stable items-center justify-end gap-2 overflow-y-auto pr-6 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>{{ $t('workspacePanel.partnerNodes.autoEnableLabel') }}</span>
|
||||
<!-- Both strings occupy the same grid cell so its width is fixed to the
|
||||
longer one; only the active label is visible, so the row never reflows. -->
|
||||
<span class="grid justify-items-end text-base-foreground">
|
||||
<span
|
||||
:class="cn('col-start-1 row-start-1', !autoEnableNew && 'invisible')"
|
||||
>
|
||||
{{ $t('workspacePanel.partnerNodes.autoEnabled') }}
|
||||
</span>
|
||||
<span
|
||||
:class="cn('col-start-1 row-start-1', autoEnableNew && 'invisible')"
|
||||
>
|
||||
{{ $t('workspacePanel.partnerNodes.autoDisabled') }}
|
||||
</span>
|
||||
</span>
|
||||
<Switch
|
||||
:model-value="autoEnableNew"
|
||||
@update:model-value="setAutoEnableNew"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bulk selection toolbar: overlaid so toggling it doesn't reflow the panel -->
|
||||
<div class="absolute inset-x-0 bottom-0">
|
||||
<Transition
|
||||
enter-active-class="transition-opacity duration-150"
|
||||
leave-active-class="transition-opacity duration-150"
|
||||
enter-from-class="opacity-0"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<SelectionBar
|
||||
v-if="selectedCount > 0"
|
||||
:label="
|
||||
$t('workspacePanel.partnerNodes.selectedCount', selectedCount)
|
||||
"
|
||||
:deselect-label="$t('workspacePanel.partnerNodes.clearSelection')"
|
||||
@deselect="clearSelection"
|
||||
>
|
||||
<Switch :model-value="bulkEnabled" @update:model-value="applyBulk" />
|
||||
</SelectionBar>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SelectionBar from '@/components/common/SelectionBar.vue'
|
||||
import Checkbox from '@/components/ui/checkbox/Checkbox.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Switch from '@/components/ui/switch/Switch.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 BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
|
||||
import PartnerBadge from '@/platform/workspace/components/dialogs/settings/PartnerBadge.vue'
|
||||
import { usePartnerNodes } from '@/platform/workspace/composables/usePartnerNodes'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
autoEnableNew,
|
||||
searchQuery,
|
||||
sortField,
|
||||
sortDirection,
|
||||
selectedIds,
|
||||
selectedCount,
|
||||
allFilteredSelected,
|
||||
filteredNodes,
|
||||
fetch,
|
||||
toggleSort,
|
||||
setEnabled,
|
||||
setSelectedEnabled,
|
||||
setAutoEnableNew,
|
||||
toggleSelection,
|
||||
toggleSelectAll,
|
||||
clearSelection
|
||||
} = usePartnerNodes()
|
||||
|
||||
const hasSelection = computed(() => selectedCount.value > 0)
|
||||
|
||||
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: 'name' | 'partner' | 'lastModified') {
|
||||
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'
|
||||
}
|
||||
|
||||
// When every selected node is enabled the bulk switch reads "on", so a toggle
|
||||
// disables the whole selection; otherwise it enables them.
|
||||
const bulkEnabled = computed(() =>
|
||||
filteredNodes.value
|
||||
.filter((n) => selectedIds.value.has(n.id))
|
||||
.every((n) => n.enabled)
|
||||
)
|
||||
|
||||
function applyBulk(value: boolean) {
|
||||
void setSelectedEnabled(value)
|
||||
}
|
||||
|
||||
function formatLastModified(iso: string | null): string {
|
||||
if (!iso) return t('workspacePanel.partnerNodes.neverModified')
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(fetch)
|
||||
</script>
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<TableRow
|
||||
:data-testid="`invite-row-${invite.id}`"
|
||||
class="group hover:bg-transparent"
|
||||
>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-full"
|
||||
:style="{ backgroundColor: userBadgeColor(invite.email) }"
|
||||
>
|
||||
<span class="text-sm font-bold text-base-foreground">
|
||||
{{ inviteInitial }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
|
||||
{{ invite.email }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground">
|
||||
{{ formatDate(invite.inviteDate) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground">
|
||||
{{ formatDate(invite.expiryDate) }}
|
||||
</TableCell>
|
||||
<TableCell v-if="canManage" class="text-right" @click.stop>
|
||||
<DropdownMenu
|
||||
:entries="menuItems"
|
||||
:modal="false"
|
||||
content-class="min-w-44"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TableCell from '@/components/ui/table/TableCell.vue'
|
||||
import TableRow from '@/components/ui/table/TableRow.vue'
|
||||
import type { PendingInvite } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
|
||||
|
||||
const { invite, canManage } = defineProps<{
|
||||
invite: PendingInvite
|
||||
canManage: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
resend: [invite: PendingInvite]
|
||||
revoke: [invite: PendingInvite]
|
||||
}>()
|
||||
|
||||
const { t, d } = useI18n()
|
||||
|
||||
const inviteInitial = computed(() => invite.email.charAt(0).toUpperCase())
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => [
|
||||
{
|
||||
label: t('workspacePanel.members.actions.resendInvite'),
|
||||
command: () => emit('resend', invite)
|
||||
},
|
||||
{
|
||||
label: t('workspacePanel.members.actions.cancelInvite'),
|
||||
command: () => emit('revoke', invite)
|
||||
}
|
||||
])
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return d(date, { dateStyle: 'medium' })
|
||||
}
|
||||
</script>
|
||||
@@ -1,85 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Slots } from 'vue'
|
||||
import { h } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import PendingInvitesList from './PendingInvitesList.vue'
|
||||
|
||||
import type { PendingInvite } from '../../../stores/teamWorkspaceStore'
|
||||
|
||||
const mockMenuClose = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/components/button/MoreButton.vue', () => ({
|
||||
default: (_: unknown, { slots }: { slots: Slots }) =>
|
||||
h('div', slots.default?.({ close: mockMenuClose }))
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
function createInvite(overrides: Partial<PendingInvite> = {}): PendingInvite {
|
||||
return {
|
||||
id: 'invite-1',
|
||||
email: 'invitee@example.com',
|
||||
inviteDate: new Date('2025-03-01'),
|
||||
expiryDate: new Date('2025-04-01'),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent(invites: PendingInvite[]) {
|
||||
return render(PendingInvitesList, {
|
||||
props: {
|
||||
invites,
|
||||
gridCols: 'grid-cols-[50%_20%_20%_10%]'
|
||||
},
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('PendingInvitesList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows the empty state without action buttons when there are no invites', () => {
|
||||
renderComponent([])
|
||||
|
||||
expect(screen.getByText('workspacePanel.members.noInvites')).toBeTruthy()
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('emits resend with the invite and closes the menu', async () => {
|
||||
const invite = createInvite({ id: 'inv-7' })
|
||||
const { emitted } = renderComponent([invite])
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'workspacePanel.members.actions.resendInvite'
|
||||
})
|
||||
)
|
||||
|
||||
expect(emitted('resend')).toEqual([[invite]])
|
||||
expect(mockMenuClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits revoke with the invite from the cancel item', async () => {
|
||||
const invite = createInvite({ id: 'inv-8' })
|
||||
const { emitted } = renderComponent([invite])
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'workspacePanel.members.actions.cancelInvite'
|
||||
})
|
||||
)
|
||||
|
||||
expect(emitted('revoke')).toEqual([[invite]])
|
||||
})
|
||||
})
|
||||
@@ -1,112 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-for="(invite, index) in invites"
|
||||
:key="invite.id"
|
||||
:class="
|
||||
cn(
|
||||
'grid w-full items-center rounded-lg p-2',
|
||||
gridCols,
|
||||
index % 2 === 1 && 'bg-secondary-background/50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary-background"
|
||||
>
|
||||
<span class="text-sm font-bold text-base-foreground">
|
||||
{{ getInviteInitial(invite.email) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ getInviteDisplayName(invite.email) }}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ invite.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ formatDate(invite.inviteDate) }}
|
||||
</span>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ formatDate(invite.expiryDate) }}
|
||||
</span>
|
||||
<div class="flex items-center justify-end">
|
||||
<MoreButton v-slot="{ close }" :aria-label="$t('g.moreOptions')">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="menuItemClass"
|
||||
@click="
|
||||
() => {
|
||||
close()
|
||||
$emit('resend', invite)
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--mail-plus] size-4" />
|
||||
<span>{{ $t('workspacePanel.members.actions.resendInvite') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="menuItemClass"
|
||||
@click="
|
||||
() => {
|
||||
close()
|
||||
$emit('revoke', invite)
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--mail-x] size-4" />
|
||||
<span>{{ $t('workspacePanel.members.actions.cancelInvite') }}</span>
|
||||
</Button>
|
||||
</MoreButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="invites.length === 0"
|
||||
class="flex w-full items-center justify-center py-8 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.members.noInvites') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { PendingInvite } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const menuItemClass = 'w-full justify-start rounded-sm px-3 py-2'
|
||||
|
||||
defineProps<{
|
||||
invites: PendingInvite[]
|
||||
gridCols: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
resend: [invite: PendingInvite]
|
||||
revoke: [invite: PendingInvite]
|
||||
}>()
|
||||
|
||||
const { d } = useI18n()
|
||||
|
||||
function getInviteDisplayName(email: string): string {
|
||||
return email.split('@')[0]
|
||||
}
|
||||
|
||||
function getInviteInitial(email: string): string {
|
||||
return email.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return d(date, { dateStyle: 'medium' })
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-4',
|
||||
// The panel runs flush to the bottom edge (BaseModalLayout 'flush'); the
|
||||
// Activity/Invoices tables keep their prior bottom gap, Overview doesn't.
|
||||
activeView !== 'overview' && 'pb-10'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex w-full items-center 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 !== 'overview'"
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('g.search')"
|
||||
size="lg"
|
||||
class="w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BillingStatusBanner />
|
||||
|
||||
<WorkspaceOverviewContent
|
||||
v-if="activeView === 'overview'"
|
||||
@navigate="setView"
|
||||
/>
|
||||
<WorkspaceActivityContent
|
||||
v-else-if="activeView === 'activity'"
|
||||
:search="searchQuery"
|
||||
/>
|
||||
<WorkspaceInvoicesContent v-else :search="searchQuery" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
|
||||
import WorkspaceActivityContent from '@/platform/workspace/components/dialogs/settings/WorkspaceActivityContent.vue'
|
||||
import WorkspaceOverviewContent from '@/platform/workspace/components/dialogs/settings/WorkspaceOverviewContent.vue'
|
||||
import WorkspaceInvoicesContent from '@/platform/workspace/components/dialogs/settings/WorkspaceInvoicesContent.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
type View = 'overview' | 'activity' | 'invoices'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { permissions } = useWorkspaceUI()
|
||||
|
||||
const tabs = computed(() => {
|
||||
const base: { key: View; label: string }[] = [
|
||||
{ key: 'overview', label: t('workspacePanel.planCredits.tabs.overview') },
|
||||
{ key: 'activity', label: t('workspacePanel.planCredits.tabs.activity') }
|
||||
]
|
||||
// Invoices are billing details — owners/admins only.
|
||||
if (permissions.value.canManageSubscription) {
|
||||
base.push({
|
||||
key: 'invoices',
|
||||
label: t('workspacePanel.planCredits.tabs.invoices')
|
||||
})
|
||||
}
|
||||
return base
|
||||
})
|
||||
const activeView = ref<View>('overview')
|
||||
const searchQuery = ref('')
|
||||
|
||||
function setView(view: View) {
|
||||
activeView.value = view
|
||||
searchQuery.value = ''
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div
|
||||
class="h-2 w-full overflow-hidden rounded-full bg-secondary-background-hover"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-credit transition-[width]"
|
||||
:style="{ width: `${clamped * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { value } = defineProps<{ value: number }>()
|
||||
|
||||
const clamped = computed(() => Math.min(1, Math.max(0, value)))
|
||||
</script>
|
||||
@@ -0,0 +1,286 @@
|
||||
<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">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('date')">
|
||||
{{ $t('workspacePanel.activity.columns.date') }}
|
||||
<i :class="sortIcon('date')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<button :class="sortHeaderClass" @click="toggleSort('user')">
|
||||
{{ $t('workspacePanel.activity.columns.user') }}
|
||||
<i :class="sortIcon('user')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-48">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('eventType')">
|
||||
{{ $t('workspacePanel.activity.columns.eventType') }}
|
||||
<i :class="sortIcon('eventType')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-32">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('detail')">
|
||||
{{ $t('workspacePanel.activity.columns.eventDetails') }}
|
||||
<i :class="sortIcon('detail')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-40">
|
||||
<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 [&:nth-child(even)>td]:bg-secondary-background/25 [&>td:first-child]:rounded-l [&>td:last-child]:rounded-r"
|
||||
>
|
||||
<TableCell class="text-sm text-muted-foreground tabular-nums">
|
||||
{{ formatDate(event.date) }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<HoverCard :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">
|
||||
<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>
|
||||
</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">
|
||||
<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="text-right text-sm text-muted-foreground tabular-nums"
|
||||
>
|
||||
{{ event.credits.toLocaleString() }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="flex h-8 items-center">
|
||||
<div v-if="canViewTeamUsage" class="flex items-center gap-6">
|
||||
<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="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 { 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'
|
||||
|
||||
const { search } = defineProps<{ search: string }>()
|
||||
|
||||
const { t, d } = useI18n()
|
||||
|
||||
const tableContainer = ref<HTMLElement | null>(null)
|
||||
const { pageSize } = useAutoPageSize(tableContainer)
|
||||
|
||||
// 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)
|
||||
|
||||
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 formatDate(date: Date): string {
|
||||
return d(date, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,139 @@
|
||||
<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">
|
||||
<button :class="sortHeaderClass" @click="toggleSort('date')">
|
||||
{{ $t('workspacePanel.invoices.columns.date') }}
|
||||
<i :class="sortIcon('date')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<button :class="sortHeaderClass" @click="toggleSort('eventType')">
|
||||
{{ $t('workspacePanel.invoices.columns.eventType') }}
|
||||
<i :class="sortIcon('eventType')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead class="w-40">
|
||||
<button
|
||||
:class="cn(sortHeaderClass, 'ml-auto')"
|
||||
@click="toggleSort('price')"
|
||||
>
|
||||
{{ $t('workspacePanel.invoices.columns.price') }}
|
||||
<i :class="sortIcon('price')" />
|
||||
</button>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="invoice in pagedItems"
|
||||
:key="invoice.id"
|
||||
class="hover:bg-transparent [&:nth-child(even)>td]:bg-secondary-background/25 [&>td:first-child]:rounded-l [&>td:last-child]:rounded-r"
|
||||
>
|
||||
<TableCell class="text-sm text-muted-foreground tabular-nums">
|
||||
{{ formatDate(invoice.date) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground">
|
||||
{{ invoice.eventType }}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="text-right text-sm text-muted-foreground tabular-nums"
|
||||
>
|
||||
{{ formatPrice(invoice.amountCents) }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="flex h-8 items-center justify-between">
|
||||
<button
|
||||
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-[inherit] text-sm text-muted-foreground transition-colors hover:text-base-foreground"
|
||||
@click="openHistory"
|
||||
>
|
||||
<i class="icon-[lucide--external-link] size-4" />
|
||||
{{ $t('workspacePanel.invoices.fullHistory') }}
|
||||
</button>
|
||||
<Pagination
|
||||
v-model:page="page"
|
||||
:total="total"
|
||||
:items-per-page="itemsPerPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
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 { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useAutoPageSize } from '@/platform/workspace/composables/useAutoPageSize'
|
||||
import { useWorkspaceInvoices } from '@/platform/workspace/composables/useWorkspaceInvoices'
|
||||
import type { InvoiceSortField } from '@/platform/workspace/composables/useWorkspaceInvoices'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { search } = defineProps<{ search: string }>()
|
||||
|
||||
const { d } = useI18n()
|
||||
const { manageSubscription } = useBillingContext()
|
||||
|
||||
const tableContainer = ref<HTMLElement | null>(null)
|
||||
const { pageSize } = useAutoPageSize(tableContainer)
|
||||
|
||||
const {
|
||||
page,
|
||||
total,
|
||||
itemsPerPage,
|
||||
pagedItems,
|
||||
sortField,
|
||||
sortDirection,
|
||||
toggleSort
|
||||
} = useWorkspaceInvoices(() => search, pageSize)
|
||||
|
||||
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: InvoiceSortField) {
|
||||
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 formatDate(date: Date): string {
|
||||
return d(date, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})
|
||||
}
|
||||
|
||||
function formatPrice(cents: number): string {
|
||||
return (cents / 100).toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
})
|
||||
}
|
||||
|
||||
function openHistory() {
|
||||
void manageSubscription()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="flex size-full flex-col">
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from '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 { workspaceRole } = useWorkspaceUI()
|
||||
const { fetchMembers, fetchPendingInvites } = useTeamWorkspaceStore()
|
||||
|
||||
onMounted(() => {
|
||||
fetchMembers()
|
||||
fetchPendingInvites()
|
||||
})
|
||||
</script>
|
||||
@@ -5,6 +5,7 @@ import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import { useWorkspaceRename } from '@/platform/workspace/composables/useWorkspaceRename'
|
||||
|
||||
import WorkspaceMenuButton from './WorkspaceMenuButton.vue'
|
||||
|
||||
@@ -75,6 +76,25 @@ describe('WorkspaceMenuButton', () => {
|
||||
mockUiConfig.value = ownerConfig
|
||||
mockIsCurrentUserOriginalOwner.value = false
|
||||
mockIsWorkspaceSubscribed.value = false
|
||||
useWorkspaceRename().stopRenaming()
|
||||
})
|
||||
|
||||
it('offers Rename to an editor and starts inline renaming', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { isRenaming } = useWorkspaceRename()
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Rename Workspace' }))
|
||||
expect(isRenaming.value).toBe(true)
|
||||
})
|
||||
|
||||
it('hides Rename from a member', () => {
|
||||
mockUiConfig.value = memberConfig
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Rename Workspace' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets a member leave and offers no destructive workspace actions', () => {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<template>
|
||||
<DropdownMenu :entries="menuItems">
|
||||
<DropdownMenu
|
||||
v-if="menuItems.length > 0"
|
||||
:entries="menuItems"
|
||||
:modal="false"
|
||||
@close-auto-focus="onMenuCloseAutoFocus"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
variant="secondary"
|
||||
size="icon-lg"
|
||||
class="rounded-lg"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
@@ -21,20 +27,35 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkspaceRename } from '@/platform/workspace/composables/useWorkspaceRename'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
showLeaveWorkspaceDialog,
|
||||
showDeleteWorkspaceDialog,
|
||||
showEditWorkspaceDialog
|
||||
} = useDialogService()
|
||||
const { showLeaveWorkspaceDialog, showDeleteWorkspaceDialog } =
|
||||
useDialogService()
|
||||
const { isWorkspaceSubscribed, isCurrentUserOriginalOwner } = storeToRefs(
|
||||
useTeamWorkspaceStore()
|
||||
)
|
||||
const { uiConfig } = useWorkspaceUI()
|
||||
const { startRenaming } = useWorkspaceRename()
|
||||
|
||||
// Reka returns focus to the trigger when the menu closes, which would blur (and
|
||||
// so tear down) the rename input we're about to focus. Suppress that focus
|
||||
// restoration for the one close that kicks off a rename.
|
||||
let renameStarting = false
|
||||
|
||||
function beginRename() {
|
||||
renameStarting = true
|
||||
startRenaming()
|
||||
}
|
||||
|
||||
function onMenuCloseAutoFocus(event: Event) {
|
||||
if (!renameStarting) return
|
||||
renameStarting = false
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// Disable delete when the workspace has an active subscription (prevents
|
||||
// accidental deletion); uses the workspace's own status, not the global one.
|
||||
@@ -51,22 +72,21 @@ const deleteTooltip = computed(() => {
|
||||
})
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = []
|
||||
|
||||
if (uiConfig.value.showEditWorkspaceMenuItem) {
|
||||
items.push({
|
||||
label: t('workspacePanel.menu.editWorkspace'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: () => showEditWorkspaceDialog()
|
||||
})
|
||||
}
|
||||
const renameItems: MenuItem[] = uiConfig.value.showEditWorkspaceMenuItem
|
||||
? [
|
||||
{
|
||||
label: t('workspacePanel.menu.renameWorkspace'),
|
||||
command: beginRename
|
||||
}
|
||||
]
|
||||
: []
|
||||
|
||||
const destructiveItems: MenuItem[] = []
|
||||
const action = uiConfig.value.workspaceMenuAction
|
||||
if (action === 'delete') {
|
||||
items.push({
|
||||
destructiveItems.push({
|
||||
label: t('workspacePanel.menu.deleteWorkspace'),
|
||||
icon: 'pi pi-trash',
|
||||
class: isDeleteDisabled.value ? 'text-danger/50' : 'text-danger',
|
||||
class: isDeleteDisabled.value ? undefined : 'text-danger',
|
||||
disabled: isDeleteDisabled.value,
|
||||
tooltip: deleteTooltip.value,
|
||||
command: isDeleteDisabled.value
|
||||
@@ -77,23 +97,23 @@ const menuItems = computed<MenuItem[]>(() => {
|
||||
|
||||
// Members and non-creator owners can leave; the creator sees it disabled.
|
||||
if (action === 'leave' || action === 'delete') {
|
||||
items.push(
|
||||
destructiveItems.push(
|
||||
isCurrentUserOriginalOwner.value
|
||||
? {
|
||||
label: t('workspacePanel.menu.leaveWorkspace'),
|
||||
icon: 'pi pi-sign-out',
|
||||
class: 'opacity-50',
|
||||
disabled: true,
|
||||
tooltip: t('workspacePanel.menu.creatorCannotLeave')
|
||||
}
|
||||
: {
|
||||
label: t('workspacePanel.menu.leaveWorkspace'),
|
||||
icon: 'pi pi-sign-out',
|
||||
command: () => showLeaveWorkspaceDialog()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return items
|
||||
const divider: MenuItem[] =
|
||||
renameItems.length && destructiveItems.length ? [{ separator: true }] : []
|
||||
|
||||
return [...renameItems, ...divider, ...destructiveItems]
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex min-h-0 scrollbar-hide flex-1 flex-col gap-4 overflow-y-auto pb-10"
|
||||
>
|
||||
<!-- Plan + credits + member snapshot -->
|
||||
<div
|
||||
class="flex flex-col gap-6 rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm text-base-foreground">{{ plan.name }}</span>
|
||||
<p
|
||||
v-if="canManageBilling"
|
||||
class="m-0 text-2xl font-semibold text-base-foreground"
|
||||
>
|
||||
{{ formatPrice(plan.priceCents) }}
|
||||
<span class="text-base font-normal text-muted-foreground">
|
||||
/ {{ $t('workspacePanel.overview.perMonth') }}
|
||||
</span>
|
||||
</p>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{
|
||||
$t('workspacePanel.overview.renewsOn', {
|
||||
date: plan.renewalLabel
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="canManageBilling" class="flex shrink-0 items-center gap-2">
|
||||
<Button variant="secondary" size="lg">
|
||||
{{ $t('workspacePanel.overview.managePayment') }}
|
||||
</Button>
|
||||
<Button v-if="isOriginalOwner" variant="secondary" size="lg">
|
||||
{{ $t('workspacePanel.overview.changePlan') }}
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
v-if="planMenuEntries.length > 0"
|
||||
:entries="planMenuEntries"
|
||||
:modal="false"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
|
||||
variant="secondary"
|
||||
size="icon-lg"
|
||||
class="rounded-lg"
|
||||
:aria-label="$t('g.moreOptions')"
|
||||
>
|
||||
<i class="pi pi-ellipsis-h" />
|
||||
</Button>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<CreditsTile class="border-0" />
|
||||
|
||||
<!-- Member snapshot tile -->
|
||||
<div
|
||||
v-if="canManageBilling"
|
||||
class="flex flex-col gap-3 rounded-xl bg-modal-panel-background px-6 py-5"
|
||||
>
|
||||
<Tabs v-model="snapshotView">
|
||||
<TabsList>
|
||||
<TabsTrigger value="top">
|
||||
{{ $t('workspacePanel.overview.snapshot.topSpenders') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="recent">
|
||||
{{ $t('workspacePanel.overview.snapshot.recentActivity') }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div
|
||||
v-if="showSnapshotEmpty"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-3 py-6 text-center"
|
||||
>
|
||||
<i class="icon-[lucide--coins] size-6 text-muted-foreground" />
|
||||
<p class="m-0 text-sm text-base-foreground">
|
||||
{{ $t('workspacePanel.overview.snapshot.empty.title') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Table v-else>
|
||||
<TableHeader>
|
||||
<TableRow
|
||||
class="hover:bg-transparent [&>th]:h-9 [&>th]:border-b [&>th]:border-interface-stroke/60"
|
||||
>
|
||||
<TableHead>
|
||||
{{ $t('workspacePanel.overview.snapshot.user') }}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{{ $t('workspacePanel.overview.snapshot.lastActivity') }}
|
||||
</TableHead>
|
||||
<TableHead class="text-right">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{ $t('workspacePanel.overview.snapshot.creditsUsed') }}
|
||||
</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="row in snapshotRows"
|
||||
:key="row.userName"
|
||||
class="hover:bg-transparent [&:nth-child(even)>td]:bg-secondary-background/25 [&>td:first-child]:rounded-l [&>td:last-child]:rounded-r"
|
||||
>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="flex size-5 shrink-0 items-center justify-center rounded-full"
|
||||
:style="{ backgroundColor: row.color }"
|
||||
>
|
||||
<span class="text-2xs font-bold text-base-foreground">
|
||||
{{ row.userName.charAt(0).toUpperCase() }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ row.userName }}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-sm text-muted-foreground tabular-nums">
|
||||
{{ row.lastActivity }}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class="text-right text-sm text-base-foreground tabular-nums"
|
||||
>
|
||||
{{ row.credits.toLocaleString() }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="lg"
|
||||
class="mt-auto w-full"
|
||||
@click="handleSeeMore"
|
||||
>
|
||||
{{ $t('workspacePanel.overview.seeMore') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next month invoice -->
|
||||
<div
|
||||
v-if="canManageBilling"
|
||||
class="flex items-center justify-between gap-4 rounded-2xl border border-interface-stroke/60 p-6"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ $t('workspacePanel.overview.nextInvoice') }}
|
||||
</span>
|
||||
<p class="m-0 text-2xl font-semibold text-base-foreground">
|
||||
{{ formatPrice(nextInvoiceCents) }}
|
||||
<span class="text-base font-normal text-muted-foreground">
|
||||
{{ $t('workspacePanel.overview.usd') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@click="emit('navigate', 'invoices')"
|
||||
>
|
||||
{{ $t('workspacePanel.planCredits.tabs.invoices') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Credit auto-reload -->
|
||||
<AutoReloadSection v-if="canManageBilling" />
|
||||
|
||||
<!-- Footer links: mt-auto floats them to the bottom when the content is
|
||||
short (e.g. a member's near-empty tab); when content overflows, mt-auto
|
||||
collapses and they scroll below it. -->
|
||||
<div
|
||||
class="mt-auto flex h-8 shrink-0 items-center gap-4 text-sm text-muted-foreground"
|
||||
>
|
||||
<a
|
||||
:href="learnMoreUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex cursor-pointer items-center gap-1 text-muted-foreground no-underline transition-colors hover:text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
{{ $t('workspacePanel.overview.learnMore') }}
|
||||
</a>
|
||||
<a
|
||||
:href="partnerNodesPricingUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex cursor-pointer items-center gap-1 text-muted-foreground no-underline transition-colors hover:text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
{{ $t('workspacePanel.overview.pricingTable') }}
|
||||
</a>
|
||||
<button
|
||||
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-[inherit] text-sm text-muted-foreground transition-colors hover:text-base-foreground"
|
||||
@click="openSupport"
|
||||
>
|
||||
<i class="icon-[lucide--message-circle] size-4" />
|
||||
{{ $t('workspacePanel.overview.messageSupport') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import Button from '@/components/ui/button/Button.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 Tabs from '@/components/ui/tabs/Tabs.vue'
|
||||
import TabsList from '@/components/ui/tabs/TabsList.vue'
|
||||
import TabsTrigger from '@/components/ui/tabs/TabsTrigger.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
|
||||
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
|
||||
import AutoReloadSection from '@/platform/workspace/components/dialogs/settings/AutoReloadSection.vue'
|
||||
import { buildSupportUrl } from '@/platform/support/config'
|
||||
import { requestMembersSort } from '@/platform/workspace/composables/useMembersPanel'
|
||||
import { useWorkspaceOverview } from '@/platform/workspace/composables/useWorkspaceOverview'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
const emit = defineEmits<{ navigate: [view: 'activity' | 'invoices'] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Plan lifecycle actions are for the workspace creator (Owner) only; Admins and
|
||||
// Members don't see Change plan or the overflow menu.
|
||||
const {
|
||||
isOriginalOwner,
|
||||
isActiveSubscription,
|
||||
isTeamPlanCancelled,
|
||||
permissions
|
||||
} = useWorkspaceUI()
|
||||
|
||||
// Members can't manage or view billing details — only the credit balance. Gates
|
||||
// the plan price, payment/plan actions, snapshot, next invoice, and auto-reload.
|
||||
const canManageBilling = computed(() => permissions.value.canManageSubscription)
|
||||
const { isFreeTier, subscription } = useBillingContext()
|
||||
const { showCancelSubscriptionDialog } = useDialogService()
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const learnMoreUrl = 'https://docs.comfy.org/get_started/cloud'
|
||||
const partnerNodesPricingUrl = buildDocsUrl(docsPaths.partnerNodesPricing, {
|
||||
includeLocale: true
|
||||
})
|
||||
|
||||
const { userEmail, resolvedUserInfo } = useCurrentUser()
|
||||
function openSupport() {
|
||||
const url = buildSupportUrl({
|
||||
userEmail: userEmail.value,
|
||||
userId: resolvedUserInfo.value?.id
|
||||
})
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const canCancelPlan = computed(
|
||||
() =>
|
||||
isOriginalOwner.value &&
|
||||
isActiveSubscription.value &&
|
||||
!isTeamPlanCancelled.value &&
|
||||
!isFreeTier.value
|
||||
)
|
||||
|
||||
const planMenuEntries = computed<MenuItem[]>(() =>
|
||||
canCancelPlan.value
|
||||
? [
|
||||
{
|
||||
label: t('subscription.cancelPlan'),
|
||||
command: () =>
|
||||
void showCancelSubscriptionDialog(
|
||||
subscription.value?.endDate ?? undefined
|
||||
)
|
||||
}
|
||||
]
|
||||
: []
|
||||
)
|
||||
|
||||
const { plan, nextInvoiceCents, topSpenders, recentActivity } =
|
||||
useWorkspaceOverview()
|
||||
|
||||
const { navigateToPanel } = useSettingsNavigation()
|
||||
|
||||
const snapshotView = ref('top')
|
||||
const snapshotRows = computed(() =>
|
||||
snapshotView.value === 'top' ? topSpenders.value : recentActivity.value
|
||||
)
|
||||
|
||||
// A top-spenders leaderboard of all-zeros (a fresh billing cycle) is
|
||||
// meaningless, so swap in an empty state. Recent activity persists across
|
||||
// cycles, so it keeps its list.
|
||||
const showSnapshotEmpty = computed(
|
||||
() =>
|
||||
snapshotView.value === 'top' &&
|
||||
!topSpenders.value.some((row) => row.credits > 0)
|
||||
)
|
||||
|
||||
// Top spenders → the Members panel pre-sorted by credit usage; Recent activity
|
||||
// → the Activity tab.
|
||||
function handleSeeMore() {
|
||||
if (snapshotView.value === 'recent') {
|
||||
emit('navigate', 'activity')
|
||||
return
|
||||
}
|
||||
requestMembersSort('credits')
|
||||
navigateToPanel('workspace-members')
|
||||
}
|
||||
|
||||
function formatPrice(cents: number): string {
|
||||
return (cents / 100).toLocaleString('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0
|
||||
})
|
||||
}
|
||||
</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>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<div class="group relative size-12 shrink-0">
|
||||
<WorkspaceProfilePic
|
||||
class="size-12 rounded-lg text-2xl"
|
||||
:workspace-name="workspaceName"
|
||||
:image-url="imageUrl ?? undefined"
|
||||
/>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg border-none bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
:aria-label="$t('workspacePanel.editWorkspaceImage')"
|
||||
@click="pickImage"
|
||||
>
|
||||
<i class="icon-[lucide--pencil] size-4 text-white" />
|
||||
</button>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-if="isRenaming"
|
||||
ref="inputRef"
|
||||
v-model="draftName"
|
||||
:maxlength="MAX_NAME_LENGTH"
|
||||
class="min-w-0 flex-1 appearance-none border-none bg-transparent p-0 font-[inherit] text-2xl font-semibold text-base-foreground outline-none"
|
||||
@keydown.enter="commit"
|
||||
@keydown.esc="cancel"
|
||||
@blur="commit"
|
||||
/>
|
||||
<h1
|
||||
v-else
|
||||
v-tooltip="
|
||||
canEdit
|
||||
? { value: $t('workspacePanel.doubleClickToRename'), showDelay: 300 }
|
||||
: undefined
|
||||
"
|
||||
class="truncate text-2xl font-semibold text-base-foreground"
|
||||
@dblclick="beginRename"
|
||||
>
|
||||
{{ workspaceName }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="isRenaming && remaining <= 10"
|
||||
class="shrink-0 text-sm text-muted-foreground tabular-nums"
|
||||
>
|
||||
{{ $t('workspacePanel.charactersLeft', remaining) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import { useWorkspaceRename } from '@/platform/workspace/composables/useWorkspaceRename'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const store = useTeamWorkspaceStore()
|
||||
const { workspaceName } = storeToRefs(store)
|
||||
const { uiConfig } = useWorkspaceUI()
|
||||
|
||||
const MAX_NAME_LENGTH = WORKSPACE_NAME_MAX_LENGTH
|
||||
|
||||
// Renaming is gated to Owner + Admins (and the sole owner of a personal
|
||||
// workspace); Members never see the affordance.
|
||||
const canEdit = computed(() => uiConfig.value.showEditWorkspaceMenuItem)
|
||||
|
||||
const { isRenaming, startRenaming, stopRenaming } = useWorkspaceRename()
|
||||
const draftName = ref('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// A single entry point (double-click here or the "Rename" menu item) flips
|
||||
// `isRenaming`; seed the draft and focus the field once the input mounts.
|
||||
watch(isRenaming, (renaming) => {
|
||||
if (!renaming) return
|
||||
draftName.value = workspaceName.value
|
||||
void nextTick(() => {
|
||||
inputRef.value?.focus()
|
||||
inputRef.value?.select()
|
||||
})
|
||||
})
|
||||
|
||||
// Surface the limit only as the user approaches it, to keep the header quiet.
|
||||
const remaining = computed(() => MAX_NAME_LENGTH - draftName.value.length)
|
||||
|
||||
// Client-side only preview (prototype): the picked image is held locally, not
|
||||
// uploaded or persisted. Resets on reload.
|
||||
const imageUrl = ref<string | null>(null)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function pickImage() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function onFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
imageUrl.value = reader.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
function beginRename() {
|
||||
if (!canEdit.value) return
|
||||
startRenaming()
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
if (!isRenaming.value) return
|
||||
stopRenaming()
|
||||
const name = draftName.value.trim()
|
||||
if (!name || name === workspaceName.value) return
|
||||
try {
|
||||
await store.updateWorkspaceName(name)
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.toast.failedToUpdateWorkspace')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
stopRenaming()
|
||||
}
|
||||
</script>
|
||||
44
src/platform/workspace/composables/useAutoPageSize.ts
Normal file
44
src/platform/workspace/composables/useAutoPageSize.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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
|
||||
const rowHeight =
|
||||
container.querySelector<HTMLElement>('tbody tr')?.offsetHeight ||
|
||||
FALLBACK_ROW_HEIGHT
|
||||
const headerHeight =
|
||||
container.querySelector<HTMLElement>('thead')?.offsetHeight ?? 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)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
onScopeDispose(() => observer?.disconnect())
|
||||
|
||||
return { pageSize }
|
||||
}
|
||||
176
src/platform/workspace/composables/useAutoReload.ts
Normal file
176
src/platform/workspace/composables/useAutoReload.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
import { creditsToCents } from '@/base/credits/comfyCredits'
|
||||
|
||||
// Prototype: there is no auto-reload API yet, so the config is a client-side
|
||||
// singleton. The initial scenario is seeded from a dev-harness key; the dialog
|
||||
// and the header toggle mutate it live.
|
||||
type AutoReloadScenario =
|
||||
| 'notset'
|
||||
| 'nobudget'
|
||||
| 'healthy'
|
||||
| 'nearlimit'
|
||||
| 'paused'
|
||||
| 'off'
|
||||
|
||||
const SCENARIOS: readonly AutoReloadScenario[] = [
|
||||
'notset',
|
||||
'nobudget',
|
||||
'healthy',
|
||||
'nearlimit',
|
||||
'paused',
|
||||
'off'
|
||||
]
|
||||
|
||||
const HARNESS_KEY = 'cbm.autoReload'
|
||||
|
||||
interface AutoReloadConfig {
|
||||
configured: boolean
|
||||
enabled: boolean
|
||||
thresholdCredits: number
|
||||
reloadCredits: number
|
||||
// null = no monthly budget set
|
||||
monthlyBudgetCents: number | null
|
||||
spentThisCycleCents: number
|
||||
lastReload: { date: Date; credits: number } | null
|
||||
}
|
||||
|
||||
function daysAgo(n: number): Date {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - n)
|
||||
return d
|
||||
}
|
||||
|
||||
function resetDate(): Date {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + 20)
|
||||
return d
|
||||
}
|
||||
|
||||
const DEFAULT_RELOAD = { thresholdCredits: 1000, reloadCredits: 5000 }
|
||||
const BUDGET_CENTS = 50_000 // $500
|
||||
const lastReload = () => ({ date: daysAgo(3), credits: 5000 })
|
||||
|
||||
function scenarioConfig(scenario: AutoReloadScenario): AutoReloadConfig {
|
||||
const base: AutoReloadConfig = {
|
||||
configured: true,
|
||||
enabled: true,
|
||||
...DEFAULT_RELOAD,
|
||||
monthlyBudgetCents: BUDGET_CENTS,
|
||||
spentThisCycleCents: 4800, // $48
|
||||
lastReload: lastReload()
|
||||
}
|
||||
switch (scenario) {
|
||||
case 'notset':
|
||||
return { ...base, configured: false, lastReload: null }
|
||||
case 'nobudget':
|
||||
return { ...base, monthlyBudgetCents: null }
|
||||
case 'nearlimit':
|
||||
return { ...base, spentThisCycleCents: 47_600 } // ~1 reload left
|
||||
case 'paused':
|
||||
return { ...base, spentThisCycleCents: BUDGET_CENTS } // budget exhausted
|
||||
case 'off':
|
||||
return { ...base, enabled: false }
|
||||
case 'healthy':
|
||||
default:
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
function initialScenario(): AutoReloadScenario {
|
||||
try {
|
||||
const stored = localStorage.getItem(
|
||||
HARNESS_KEY
|
||||
) as AutoReloadScenario | null
|
||||
if (stored && SCENARIOS.includes(stored)) return stored
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return 'healthy'
|
||||
}
|
||||
|
||||
const config = reactive<AutoReloadConfig>(scenarioConfig(initialScenario()))
|
||||
const cycleResetDate = resetDate()
|
||||
|
||||
// Dev bridge: the billing-mock harness dispatches this to swap scenarios live,
|
||||
// so flipping the picker updates the tile in place instead of reloading the page.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('cbm:autoReload', (event) => {
|
||||
const scenario = (event as CustomEvent<AutoReloadScenario>).detail
|
||||
if (SCENARIOS.includes(scenario)) {
|
||||
Object.assign(config, scenarioConfig(scenario))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useAutoReload() {
|
||||
const isConfigured = computed(() => config.configured)
|
||||
const isEnabled = computed(() => config.enabled)
|
||||
const hasBudget = computed(() => config.monthlyBudgetCents != null)
|
||||
|
||||
const reloadCostCents = computed(() => creditsToCents(config.reloadCredits))
|
||||
|
||||
const budgetLeftCents = computed(() =>
|
||||
config.monthlyBudgetCents == null
|
||||
? 0
|
||||
: Math.max(0, config.monthlyBudgetCents - config.spentThisCycleCents)
|
||||
)
|
||||
|
||||
const budgetUsedFraction = computed(() =>
|
||||
config.monthlyBudgetCents
|
||||
? Math.min(1, config.spentThisCycleCents / config.monthlyBudgetCents)
|
||||
: 0
|
||||
)
|
||||
|
||||
const reloadsLeft = computed(() =>
|
||||
hasBudget.value
|
||||
? Math.floor(budgetLeftCents.value / reloadCostCents.value)
|
||||
: null
|
||||
)
|
||||
|
||||
// Budget drained while still enabled → auto-reload can't fire, so it's paused.
|
||||
const isPaused = computed(
|
||||
() => config.enabled && hasBudget.value && budgetLeftCents.value <= 0
|
||||
)
|
||||
|
||||
// One reload (or none) of headroom left before the budget pauses it.
|
||||
const isWarning = computed(
|
||||
() =>
|
||||
config.enabled &&
|
||||
hasBudget.value &&
|
||||
!isPaused.value &&
|
||||
(reloadsLeft.value ?? Infinity) <= 1
|
||||
)
|
||||
|
||||
function setEnabled(value: boolean) {
|
||||
config.enabled = value
|
||||
}
|
||||
|
||||
function save(next: {
|
||||
thresholdCredits: number
|
||||
reloadCredits: number
|
||||
monthlyBudgetCents: number | null
|
||||
}) {
|
||||
config.configured = true
|
||||
config.enabled = true
|
||||
config.thresholdCredits = next.thresholdCredits
|
||||
config.reloadCredits = next.reloadCredits
|
||||
config.monthlyBudgetCents = next.monthlyBudgetCents
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
cycleResetDate,
|
||||
isConfigured,
|
||||
isEnabled,
|
||||
hasBudget,
|
||||
reloadCostCents,
|
||||
budgetLeftCents,
|
||||
budgetUsedFraction,
|
||||
reloadsLeft,
|
||||
isPaused,
|
||||
isWarning,
|
||||
setEnabled,
|
||||
save
|
||||
}
|
||||
}
|
||||
@@ -254,6 +254,7 @@ const mockShowChangeMemberRoleDialog = vi.fn()
|
||||
const mockShowSubscriptionDialog = vi.fn()
|
||||
const mockShowInviteMemberDialog = vi.fn()
|
||||
const mockShowInviteMemberUpsellDialog = vi.fn()
|
||||
const mockShowMemberLimitDialog = vi.fn()
|
||||
|
||||
const {
|
||||
mockMembers,
|
||||
@@ -366,6 +367,9 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: mockIsActiveSubscription,
|
||||
subscription: mockSubscription,
|
||||
balance: { value: null },
|
||||
renewalDate: { value: null },
|
||||
fetchBalance: vi.fn(),
|
||||
getMaxSeats: (tierKey: string) => {
|
||||
const seats: Record<string, number> = {
|
||||
free: 1,
|
||||
@@ -391,7 +395,8 @@ vi.mock('@/services/dialogService', () => ({
|
||||
showRevokeInviteDialog: mockShowRevokeInviteDialog,
|
||||
showChangeMemberRoleDialog: mockShowChangeMemberRoleDialog,
|
||||
showInviteMemberDialog: mockShowInviteMemberDialog,
|
||||
showInviteMemberUpsellDialog: mockShowInviteMemberUpsellDialog
|
||||
showInviteMemberUpsellDialog: mockShowInviteMemberUpsellDialog,
|
||||
showMemberLimitDialog: mockShowMemberLimitDialog
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -593,13 +598,13 @@ describe('useMembersPanel', () => {
|
||||
|
||||
const roleItems = items[0].items ?? []
|
||||
expect(roleItems.map((i) => i.label)).toEqual([
|
||||
'workspaceSwitcher.roleOwner',
|
||||
'workspaceSwitcher.roleAdmin',
|
||||
'workspaceSwitcher.roleMember'
|
||||
])
|
||||
expect(roleItems.map((i) => i.checked)).toEqual([false, true])
|
||||
})
|
||||
|
||||
it('checks Owner for owner rows', async () => {
|
||||
it('checks Admin for owner-role rows', async () => {
|
||||
const panel = await setup()
|
||||
const items = panel.memberMenuItems(createMember({ role: 'owner' }))
|
||||
const roleItems = items[0].items ?? []
|
||||
@@ -706,14 +711,15 @@ describe('useMembersPanel', () => {
|
||||
expect(mockShowInviteMemberDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables the invite button at the member cap (30)', async () => {
|
||||
it('opens the member-limit dialog at the member cap (30)', async () => {
|
||||
mockTotalMemberSlots.value = 30
|
||||
const panel = await setup()
|
||||
expect(panel.isInviteDisabled.value).toBe(true)
|
||||
expect(panel.isInviteDisabled.value).toBe(false)
|
||||
expect(panel.inviteTooltip.value).toBe(
|
||||
'workspacePanel.inviteLimitReached'
|
||||
)
|
||||
panel.handleInviteMember()
|
||||
expect(mockShowMemberLimitDialog).toHaveBeenCalled()
|
||||
expect(mockShowInviteMemberDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -724,10 +730,12 @@ describe('useMembersPanel', () => {
|
||||
expect(panel.inviteTooltip.value).toBeNull()
|
||||
})
|
||||
|
||||
it('disables the invite button at the flat backend member cap', async () => {
|
||||
it('opens the member-limit dialog at the flat backend member cap', async () => {
|
||||
mockIsInviteLimitReached.value = true
|
||||
const panel = await setup()
|
||||
expect(panel.isInviteDisabled.value).toBe(true)
|
||||
expect(panel.isInviteDisabled.value).toBe(false)
|
||||
panel.handleInviteMember()
|
||||
expect(mockShowMemberLimitDialog).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables the invite button when not on a team plan', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
|
||||
import { useTeamPlan } from '@/platform/workspace/composables/useTeamPlan'
|
||||
@@ -20,15 +21,44 @@ import {
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
|
||||
type ActiveView = 'active' | 'pending'
|
||||
type SortField = 'inviteDate' | 'expiryDate' | 'role'
|
||||
type SortField =
|
||||
| 'email'
|
||||
| 'role'
|
||||
| 'lastActivity'
|
||||
| 'credits'
|
||||
| 'inviteDate'
|
||||
| 'expiryDate'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
// One-shot sort applied the next time the Members panel mounts, so other panels
|
||||
// can deep-link in with a preset ordering (e.g. Overview "Top spenders").
|
||||
const pendingSort = ref<SortField | null>(null)
|
||||
export function requestMembersSort(field: 'credits' | 'lastActivity') {
|
||||
pendingSort.value = field
|
||||
}
|
||||
|
||||
export function sortMembers(
|
||||
members: WorkspaceMember[],
|
||||
currentUserEmail: string | null,
|
||||
sortDirection: SortDirection,
|
||||
originalOwnerId: string | null = null
|
||||
originalOwnerId: string | null = null,
|
||||
sortField: SortField = 'role'
|
||||
): WorkspaceMember[] {
|
||||
const dir = sortDirection === 'asc' ? 1 : -1
|
||||
|
||||
if (sortField === 'email') {
|
||||
return [...members].sort((a, b) => dir * a.name.localeCompare(b.name))
|
||||
}
|
||||
if (sortField === 'lastActivity') {
|
||||
const at = (m: WorkspaceMember) => m.lastActivity?.getTime() ?? 0
|
||||
return [...members].sort((a, b) => dir * (at(a) - at(b)))
|
||||
}
|
||||
if (sortField === 'credits') {
|
||||
const used = (m: WorkspaceMember) => m.creditsUsedThisMonth ?? 0
|
||||
return [...members].sort((a, b) => dir * (used(a) - used(b)))
|
||||
}
|
||||
|
||||
// Default (role) ordering pins the creator, then groups by role, then recency.
|
||||
return [...members].sort((a, b) => {
|
||||
const aIsOriginalOwner = a.id === originalOwnerId
|
||||
const bIsOriginalOwner = b.id === originalOwnerId
|
||||
@@ -89,7 +119,7 @@ export function sortPendingInvites(
|
||||
}
|
||||
|
||||
export function useMembersPanel() {
|
||||
const { t } = useI18n()
|
||||
const { t, d } = useI18n()
|
||||
const toast = useToast()
|
||||
const { userPhotoUrl, userEmail, userDisplayName } = useCurrentUser()
|
||||
const {
|
||||
@@ -97,7 +127,8 @@ export function useMembersPanel() {
|
||||
showRevokeInviteDialog,
|
||||
showChangeMemberRoleDialog,
|
||||
showInviteMemberDialog,
|
||||
showInviteMemberUpsellDialog
|
||||
showInviteMemberUpsellDialog,
|
||||
showMemberLimitDialog
|
||||
} = useDialogService()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const {
|
||||
@@ -112,11 +143,40 @@ export function useMembersPanel() {
|
||||
const { permissions, uiConfig } = useWorkspaceUI()
|
||||
const { isOnTeamPlan, isCancelled, hasLapsedTeamPlan } = useTeamPlan()
|
||||
const subscriptionDialog = useSubscriptionDialog()
|
||||
const { balance, renewalDate, fetchBalance } = useBillingContext()
|
||||
|
||||
// The team plan caps members at a flat MAX_WORKSPACE_MEMBERS, independent of
|
||||
// the subscription tier.
|
||||
const maxSeats = computed(() => MAX_WORKSPACE_MEMBERS)
|
||||
|
||||
const memberCount = computed(() => members.value.length)
|
||||
|
||||
// Out-of-credits banner: shown once the team's balance is drained, until the
|
||||
// user dismisses it for the session. Reset date comes from the renewal cycle.
|
||||
const bannerDismissed = ref(false)
|
||||
const isOutOfCredits = computed(
|
||||
() =>
|
||||
isOnTeamPlan.value &&
|
||||
balance.value != null &&
|
||||
balance.value.amountMicros <= 0
|
||||
)
|
||||
const showOutOfCreditsBanner = computed(
|
||||
() => isOutOfCredits.value && !bannerDismissed.value
|
||||
)
|
||||
const creditResetDate = computed(() =>
|
||||
renewalDate.value
|
||||
? d(new Date(renewalDate.value), { month: 'short', day: 'numeric' })
|
||||
: null
|
||||
)
|
||||
|
||||
function dismissOutOfCreditsBanner() {
|
||||
bannerDismissed.value = true
|
||||
}
|
||||
|
||||
function handleAddCredits() {
|
||||
subscriptionDialog.show({ planMode: 'team', reason: 'out_of_credits' })
|
||||
}
|
||||
|
||||
const hasMultipleMembers = computed(() => members.value.length > 1)
|
||||
|
||||
const showSearch = computed(
|
||||
@@ -138,10 +198,10 @@ export function useMembersPanel() {
|
||||
() => isInviteLimitReached.value || totalMemberSlots.value >= maxSeats.value
|
||||
)
|
||||
|
||||
// Invite is allowed only on an active (non-cancelled) team plan that is under
|
||||
// the member cap.
|
||||
// Invite stays enabled at the seat cap so the button can surface the
|
||||
// "at the member limit" dialog; only an inactive/cancelled plan disables it.
|
||||
const isInviteDisabled = computed(
|
||||
() => !isOnTeamPlan.value || isCancelled.value || isMemberLimitReached.value
|
||||
() => !isOnTeamPlan.value || isCancelled.value
|
||||
)
|
||||
|
||||
const inviteTooltip = computed(() => {
|
||||
@@ -155,7 +215,11 @@ export function useMembersPanel() {
|
||||
void showInviteMemberUpsellDialog()
|
||||
return
|
||||
}
|
||||
if (isCancelled.value || isMemberLimitReached.value) return
|
||||
if (isCancelled.value) return
|
||||
if (isMemberLimitReached.value) {
|
||||
void showMemberLimitDialog(maxSeats.value)
|
||||
return
|
||||
}
|
||||
void showInviteMemberDialog()
|
||||
}
|
||||
|
||||
@@ -170,8 +234,9 @@ export function useMembersPanel() {
|
||||
|
||||
const searchQuery = ref('')
|
||||
const activeView = ref<ActiveView>('active')
|
||||
const sortField = ref<SortField>('inviteDate')
|
||||
const sortField = ref<SortField>(pendingSort.value ?? 'inviteDate')
|
||||
const sortDirection = ref<SortDirection>('desc')
|
||||
pendingSort.value = null
|
||||
|
||||
function roleMenuItem(
|
||||
member: WorkspaceMember,
|
||||
@@ -190,7 +255,7 @@ export function useMembersPanel() {
|
||||
{
|
||||
label: t('workspacePanel.members.actions.changeRole'),
|
||||
items: [
|
||||
roleMenuItem(member, 'owner', t('workspaceSwitcher.roleOwner')),
|
||||
roleMenuItem(member, 'owner', t('workspaceSwitcher.roleAdmin')),
|
||||
roleMenuItem(member, 'member', t('workspaceSwitcher.roleMember'))
|
||||
]
|
||||
},
|
||||
@@ -215,13 +280,14 @@ export function useMembersPanel() {
|
||||
searched,
|
||||
userEmail.value ?? null,
|
||||
sortDirection.value,
|
||||
originalOwnerId.value
|
||||
originalOwnerId.value,
|
||||
sortField.value
|
||||
)
|
||||
})
|
||||
|
||||
// Built once per member list rather than per row on every render, so an
|
||||
// unrelated re-render (e.g. typing in the search box) doesn't rebuild every
|
||||
// row's menu and churn MemberListItem's props.
|
||||
// row's menu and churn MemberTableRow's props.
|
||||
const memberMenus = computed(
|
||||
() => new Map(filteredMembers.value.map((m) => [m.id, memberMenuItems(m)]))
|
||||
)
|
||||
@@ -286,9 +352,15 @@ export function useMembersPanel() {
|
||||
sortField,
|
||||
sortDirection,
|
||||
maxSeats,
|
||||
memberCount,
|
||||
isOnTeamPlan,
|
||||
hasLapsedTeamPlan,
|
||||
hasMultipleMembers,
|
||||
showOutOfCreditsBanner,
|
||||
creditResetDate,
|
||||
dismissOutOfCreditsBanner,
|
||||
handleAddCredits,
|
||||
fetchBalance,
|
||||
showSearch,
|
||||
showViewTabs,
|
||||
showInviteButton,
|
||||
|
||||
122
src/platform/workspace/composables/usePartnerNodes.test.ts
Normal file
122
src/platform/workspace/composables/usePartnerNodes.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { PartnerNode } from '@/platform/workspace/api/partnerNodesApi'
|
||||
import { partnerNodesApi } from '@/platform/workspace/api/partnerNodesApi'
|
||||
import { usePartnerNodes } from '@/platform/workspace/composables/usePartnerNodes'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key })
|
||||
}))
|
||||
|
||||
const mockToastAdd = vi.fn()
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({ add: mockToastAdd })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/api/partnerNodesApi', () => ({
|
||||
partnerNodesApi: {
|
||||
list: vi.fn(),
|
||||
setEnabled: vi.fn(),
|
||||
setEnabledBulk: vi.fn(),
|
||||
setAutoEnableNew: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
function node(overrides: Partial<PartnerNode> = {}): PartnerNode {
|
||||
return {
|
||||
id: 'pn-1',
|
||||
name: 'Anthropic Claude',
|
||||
partner: 'Anthropic',
|
||||
last_modified: null,
|
||||
enabled: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const sampleNodes: PartnerNode[] = [
|
||||
node({ id: 'a', name: 'Zeta Node', partner: 'BFL', enabled: true }),
|
||||
node({ id: 'b', name: 'Alpha Node', partner: 'Anthropic', enabled: false }),
|
||||
node({ id: 'c', name: 'Beta Node', partner: 'BFL', enabled: true })
|
||||
]
|
||||
|
||||
async function setupLoaded() {
|
||||
vi.mocked(partnerNodesApi.list).mockResolvedValue({
|
||||
partner_nodes: sampleNodes.map((n) => ({ ...n })),
|
||||
auto_enable_new: true
|
||||
})
|
||||
const pn = usePartnerNodes()
|
||||
await pn.fetch()
|
||||
return pn
|
||||
}
|
||||
|
||||
describe('usePartnerNodes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('loads nodes and the auto-enable default', async () => {
|
||||
const pn = await setupLoaded()
|
||||
expect(pn.nodes.value).toHaveLength(3)
|
||||
expect(pn.autoEnableNew.value).toBe(true)
|
||||
})
|
||||
|
||||
it('sorts by name ascending by default and toggles direction', async () => {
|
||||
const pn = await setupLoaded()
|
||||
expect(pn.filteredNodes.value.map((n) => n.name)).toEqual([
|
||||
'Alpha Node',
|
||||
'Beta Node',
|
||||
'Zeta Node'
|
||||
])
|
||||
pn.toggleSort('name')
|
||||
expect(pn.filteredNodes.value.map((n) => n.name)).toEqual([
|
||||
'Zeta Node',
|
||||
'Beta Node',
|
||||
'Alpha Node'
|
||||
])
|
||||
})
|
||||
|
||||
it('filters by search across name and partner', async () => {
|
||||
const pn = await setupLoaded()
|
||||
pn.searchQuery.value = 'anthropic'
|
||||
expect(pn.filteredNodes.value.map((n) => n.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('optimistically toggles a node and calls the api', async () => {
|
||||
const pn = await setupLoaded()
|
||||
const target = pn.nodes.value.find((n) => n.id === 'b')!
|
||||
await pn.setEnabled(target, true)
|
||||
expect(pn.nodes.value.find((n) => n.id === 'b')!.enabled).toBe(true)
|
||||
expect(partnerNodesApi.setEnabled).toHaveBeenCalledWith('b', true)
|
||||
})
|
||||
|
||||
it('reverts and toasts when a toggle fails', async () => {
|
||||
const pn = await setupLoaded()
|
||||
vi.mocked(partnerNodesApi.setEnabled).mockRejectedValueOnce(new Error('x'))
|
||||
const target = pn.nodes.value.find((n) => n.id === 'a')!
|
||||
await pn.setEnabled(target, false)
|
||||
expect(pn.nodes.value.find((n) => n.id === 'a')!.enabled).toBe(true)
|
||||
expect(mockToastAdd).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bulk-toggles the current selection and keeps it selected', async () => {
|
||||
const pn = await setupLoaded()
|
||||
pn.toggleSelection('a')
|
||||
pn.toggleSelection('c')
|
||||
await pn.setSelectedEnabled(false)
|
||||
expect(pn.nodes.value.find((n) => n.id === 'a')!.enabled).toBe(false)
|
||||
expect(pn.nodes.value.find((n) => n.id === 'c')!.enabled).toBe(false)
|
||||
expect(partnerNodesApi.setEnabledBulk).toHaveBeenCalledWith(
|
||||
['a', 'c'],
|
||||
false
|
||||
)
|
||||
expect(pn.selectedCount.value).toBe(2)
|
||||
})
|
||||
|
||||
it('select-all reflects the filtered set', async () => {
|
||||
const pn = await setupLoaded()
|
||||
pn.searchQuery.value = 'BFL'
|
||||
pn.toggleSelectAll()
|
||||
expect(pn.selectedCount.value).toBe(2)
|
||||
expect(pn.allFilteredSelected.value).toBe(true)
|
||||
})
|
||||
})
|
||||
179
src/platform/workspace/composables/usePartnerNodes.ts
Normal file
179
src/platform/workspace/composables/usePartnerNodes.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { PartnerNode } from '@/platform/workspace/api/partnerNodesApi'
|
||||
import { partnerNodesApi } from '@/platform/workspace/api/partnerNodesApi'
|
||||
|
||||
type SortField = 'name' | 'partner' | 'lastModified'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
function compareNodes(
|
||||
a: PartnerNode,
|
||||
b: PartnerNode,
|
||||
field: SortField,
|
||||
direction: SortDirection
|
||||
): number {
|
||||
const dir = direction === 'asc' ? 1 : -1
|
||||
if (field === 'lastModified') {
|
||||
const av = a.last_modified ?? ''
|
||||
const bv = b.last_modified ?? ''
|
||||
return av.localeCompare(bv) * dir
|
||||
}
|
||||
const key = field === 'partner' ? 'partner' : 'name'
|
||||
return a[key].localeCompare(b[key]) * dir
|
||||
}
|
||||
|
||||
export function usePartnerNodes() {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const nodes = ref<PartnerNode[]>([])
|
||||
const autoEnableNew = ref(true)
|
||||
const isLoading = ref(false)
|
||||
|
||||
const searchQuery = ref('')
|
||||
const sortField = ref<SortField>('name')
|
||||
const sortDirection = ref<SortDirection>('asc')
|
||||
const selectedIds = ref<Set<string>>(new Set())
|
||||
|
||||
const filteredNodes = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
const filtered = nodes.value.filter(
|
||||
(n) =>
|
||||
!q ||
|
||||
n.name.toLowerCase().includes(q) ||
|
||||
n.partner.toLowerCase().includes(q)
|
||||
)
|
||||
return filtered.sort((a, b) =>
|
||||
compareNodes(a, b, sortField.value, sortDirection.value)
|
||||
)
|
||||
})
|
||||
|
||||
const selectedCount = computed(() => selectedIds.value.size)
|
||||
const allFilteredSelected = computed(
|
||||
() =>
|
||||
filteredNodes.value.length > 0 &&
|
||||
filteredNodes.value.every((n) => selectedIds.value.has(n.id))
|
||||
)
|
||||
|
||||
async function fetch() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const data = await partnerNodesApi.list()
|
||||
nodes.value = data.partner_nodes
|
||||
autoEnableNew.value = data.auto_enable_new
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.partnerNodes.loadError')
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSort(field: SortField) {
|
||||
if (sortField.value === field) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortField.value = field
|
||||
sortDirection.value = 'asc'
|
||||
}
|
||||
}
|
||||
|
||||
function applyEnabled(ids: string[], enabled: boolean) {
|
||||
const idSet = new Set(ids)
|
||||
const now = new Date().toISOString()
|
||||
nodes.value = nodes.value.map((n) =>
|
||||
idSet.has(n.id) ? { ...n, enabled, last_modified: now } : n
|
||||
)
|
||||
}
|
||||
|
||||
async function setEnabled(node: PartnerNode, enabled: boolean) {
|
||||
const previous = node.enabled
|
||||
applyEnabled([node.id], enabled)
|
||||
try {
|
||||
await partnerNodesApi.setEnabled(node.id, enabled)
|
||||
} catch {
|
||||
applyEnabled([node.id], previous)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.partnerNodes.updateError')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function setSelectedEnabled(enabled: boolean) {
|
||||
const ids = [...selectedIds.value]
|
||||
if (ids.length === 0) return
|
||||
const previous = new Map(nodes.value.map((n) => [n.id, n.enabled]))
|
||||
applyEnabled(ids, enabled)
|
||||
try {
|
||||
// Keep the selection after a bulk toggle so the user can flip it again.
|
||||
await partnerNodesApi.setEnabledBulk(ids, enabled)
|
||||
} catch {
|
||||
nodes.value = nodes.value.map((n) =>
|
||||
previous.has(n.id) ? { ...n, enabled: previous.get(n.id)! } : n
|
||||
)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.partnerNodes.updateError')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function setAutoEnableNew(value: boolean) {
|
||||
const previous = autoEnableNew.value
|
||||
autoEnableNew.value = value
|
||||
try {
|
||||
await partnerNodesApi.setAutoEnableNew(value)
|
||||
} catch {
|
||||
autoEnableNew.value = previous
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.partnerNodes.updateError')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelection(id: string) {
|
||||
const next = new Set(selectedIds.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
selectedIds.value = next
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allFilteredSelected.value) {
|
||||
clearSelection()
|
||||
return
|
||||
}
|
||||
selectedIds.value = new Set(filteredNodes.value.map((n) => n.id))
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedIds.value = new Set()
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
autoEnableNew,
|
||||
isLoading,
|
||||
searchQuery,
|
||||
sortField,
|
||||
sortDirection,
|
||||
selectedIds,
|
||||
selectedCount,
|
||||
allFilteredSelected,
|
||||
filteredNodes,
|
||||
fetch,
|
||||
toggleSort,
|
||||
setEnabled,
|
||||
setSelectedEnabled,
|
||||
setAutoEnableNew,
|
||||
toggleSelection,
|
||||
toggleSelectAll,
|
||||
clearSelection
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useWorkspaceActivity } from './useWorkspaceActivity'
|
||||
|
||||
describe('useWorkspaceActivity', () => {
|
||||
it('paginates to the requested page size', () => {
|
||||
const { itemsPerPage, pagedItems } = useWorkspaceActivity('', 11)
|
||||
expect(pagedItems.value).toHaveLength(itemsPerPage.value)
|
||||
})
|
||||
|
||||
it('aggregates per-user totals and latest activity for the hover card', () => {
|
||||
const { pagedItems, userSummaries } = useWorkspaceActivity('', 11)
|
||||
const sample = pagedItems.value[0]
|
||||
const summary = userSummaries.value.get(sample.userName)
|
||||
|
||||
expect(summary).toBeDefined()
|
||||
// The rollup covers every event for the user, so it is at least one row.
|
||||
expect(summary!.totalCredits).toBeGreaterThanOrEqual(sample.credits)
|
||||
expect(summary!.lastActivity.getTime()).toBeGreaterThanOrEqual(
|
||||
sample.date.getTime()
|
||||
)
|
||||
})
|
||||
})
|
||||
158
src/platform/workspace/composables/useWorkspaceActivity.ts
Normal file
158
src/platform/workspace/composables/useWorkspaceActivity.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
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
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
totalCredits: number
|
||||
lastActivity: Date
|
||||
}
|
||||
|
||||
// Prototype mock: there is no usage-activity endpoint yet, so the event list is
|
||||
// generated client-side and paginated in the browser.
|
||||
const USERS = ['Yuta', 'Jane', 'Rob', 'Min', 'Alice', 'Priya', 'Diego']
|
||||
const EVENT_TYPES = ['Cloud workflow', 'Partner node usage']
|
||||
const DETAILS = ['1m 4s', '13m 4s', '59s', '1m 1s', '56s', '2m 12s', '48s']
|
||||
const CREDITS = [514, 15, 512, 520, 513, 516, 88]
|
||||
const PARTNER_NODES = [
|
||||
'Nano Banana Pro',
|
||||
'Kling Video',
|
||||
'Flux.1 Kontext [pro] Image',
|
||||
'OpenAI GPT Image',
|
||||
'Runway Gen-4 Image',
|
||||
'Luma Ray 2',
|
||||
'ByteDance Seedance 2.0',
|
||||
'Ideogram V4'
|
||||
]
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
const BASE = new Date('2026-02-25T18:30:00').getTime()
|
||||
|
||||
function mockActivity(): ActivityEvent[] {
|
||||
return Array.from({ length: 44 }, (_, i) => {
|
||||
const eventType = EVENT_TYPES[i % EVENT_TYPES.length]
|
||||
return {
|
||||
id: `act-${i}`,
|
||||
date: new Date(BASE - i * 7 * HOUR_MS),
|
||||
userName: USERS[i % USERS.length],
|
||||
eventType,
|
||||
detail: DETAILS[i % DETAILS.length],
|
||||
credits: CREDITS[i % CREDITS.length],
|
||||
partnerNode:
|
||||
eventType === 'Partner node usage'
|
||||
? PARTNER_NODES[i % PARTNER_NODES.length]
|
||||
: undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type ActivitySortField =
|
||||
| 'date'
|
||||
| 'user'
|
||||
| 'eventType'
|
||||
| 'detail'
|
||||
| 'credits'
|
||||
|
||||
export function useWorkspaceActivity(
|
||||
search: MaybeRefOrGetter<string>,
|
||||
pageSize: MaybeRefOrGetter<number>,
|
||||
selfName: MaybeRefOrGetter<string | null> = null
|
||||
) {
|
||||
const all = mockActivity()
|
||||
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. There's no per-user endpoint in this
|
||||
// prototype, so present the mock events as the member's own history.
|
||||
const base = computed<ActivityEvent[]>(() => {
|
||||
const self = toValue(selfName)
|
||||
return self ? all.map((event) => ({ ...event, userName: self })) : all
|
||||
})
|
||||
|
||||
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) {
|
||||
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,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { useWorkspaceInvoices } from './useWorkspaceInvoices'
|
||||
|
||||
describe('useWorkspaceInvoices', () => {
|
||||
it('paginates the list to the requested page size', () => {
|
||||
const { page, total, itemsPerPage, pagedItems } = useWorkspaceInvoices(
|
||||
'',
|
||||
11
|
||||
)
|
||||
expect(pagedItems.value).toHaveLength(itemsPerPage.value)
|
||||
|
||||
const firstIds = pagedItems.value.map((i) => i.id)
|
||||
page.value = 2
|
||||
expect(pagedItems.value.map((i) => i.id)).not.toEqual(firstIds)
|
||||
|
||||
const lastPage = Math.ceil(total.value / itemsPerPage.value)
|
||||
page.value = lastPage
|
||||
expect(pagedItems.value.length).toBeGreaterThan(0)
|
||||
expect(pagedItems.value.length).toBeLessThanOrEqual(itemsPerPage.value)
|
||||
})
|
||||
|
||||
it('filters by event type and snaps the page back into range', async () => {
|
||||
const search = ref('')
|
||||
const { page, total, itemsPerPage, pagedItems } = useWorkspaceInvoices(
|
||||
search,
|
||||
11
|
||||
)
|
||||
const fullTotal = total.value
|
||||
|
||||
page.value = 3
|
||||
search.value = 'subscription'
|
||||
await nextTick()
|
||||
|
||||
expect(total.value).toBeLessThan(fullTotal)
|
||||
expect(
|
||||
pagedItems.value.every((i) => i.eventType === 'Subscription payment')
|
||||
).toBe(true)
|
||||
// Page snaps back within range rather than stranding an empty page.
|
||||
expect(page.value).toBeLessThanOrEqual(
|
||||
Math.ceil(total.value / itemsPerPage.value)
|
||||
)
|
||||
expect(pagedItems.value.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('sorts by price ascending and descending', () => {
|
||||
const { pagedItems, sortDirection, toggleSort } = useWorkspaceInvoices(
|
||||
'',
|
||||
11
|
||||
)
|
||||
|
||||
toggleSort('price')
|
||||
const desc = pagedItems.value.map((i) => i.amountCents)
|
||||
expect([...desc].sort((a, b) => b - a)).toEqual(desc)
|
||||
|
||||
toggleSort('price')
|
||||
expect(sortDirection.value).toBe('asc')
|
||||
const asc = pagedItems.value.map((i) => i.amountCents)
|
||||
expect([...asc].sort((a, b) => a - b)).toEqual(asc)
|
||||
})
|
||||
})
|
||||
98
src/platform/workspace/composables/useWorkspaceInvoices.ts
Normal file
98
src/platform/workspace/composables/useWorkspaceInvoices.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { computed, ref, toValue, watch } from 'vue'
|
||||
|
||||
export interface Invoice {
|
||||
id: string
|
||||
date: Date
|
||||
eventType: string
|
||||
amountCents: number
|
||||
}
|
||||
|
||||
// Prototype mock: there is no billing-history endpoint yet, so the invoice
|
||||
// list is generated client-side and paginated in the browser.
|
||||
const INVOICE_KINDS: Array<[eventType: string, amountCents: number]> = [
|
||||
['Subscription payment', 32000],
|
||||
['Auto-reload', 2400],
|
||||
['Auto-reload', 2400],
|
||||
['Additional credit top-up', 10000],
|
||||
['Auto-reload', 2400],
|
||||
['Subscription payment', 32000]
|
||||
]
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const BASE = new Date('2026-02-20T18:30:00').getTime()
|
||||
|
||||
function mockInvoices(): Invoice[] {
|
||||
return Array.from({ length: 44 }, (_, i) => {
|
||||
const [eventType, amountCents] = INVOICE_KINDS[i % INVOICE_KINDS.length]
|
||||
return {
|
||||
id: `inv-${i}`,
|
||||
date: new Date(BASE - i * DAY_MS),
|
||||
eventType,
|
||||
amountCents
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type InvoiceSortField = 'date' | 'eventType' | 'price'
|
||||
|
||||
export function useWorkspaceInvoices(
|
||||
search: MaybeRefOrGetter<string>,
|
||||
pageSize: MaybeRefOrGetter<number>
|
||||
) {
|
||||
const all = mockInvoices()
|
||||
const page = ref(1)
|
||||
const perPage = computed(() => Math.max(1, toValue(pageSize)))
|
||||
const sortField = ref<InvoiceSortField>('date')
|
||||
const sortDirection = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = toValue(search).trim().toLowerCase()
|
||||
if (!q) return all
|
||||
return all.filter((invoice) => invoice.eventType.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
const sorted = computed(() => {
|
||||
const dir = sortDirection.value === 'asc' ? 1 : -1
|
||||
return [...filtered.value].sort((a, b) => {
|
||||
if (sortField.value === 'price')
|
||||
return dir * (a.amountCents - b.amountCents)
|
||||
if (sortField.value === 'eventType')
|
||||
return dir * a.eventType.localeCompare(b.eventType)
|
||||
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: InvoiceSortField) {
|
||||
if (sortField.value === field) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortField.value = field
|
||||
sortDirection.value = 'desc'
|
||||
}
|
||||
}
|
||||
|
||||
// Filtering or a shorter dialog can shrink the list past the current page;
|
||||
// snap back into range.
|
||||
watch([total, perPage], ([count]) => {
|
||||
const lastPage = Math.max(1, Math.ceil(count / perPage.value))
|
||||
if (page.value > lastPage) page.value = lastPage
|
||||
})
|
||||
|
||||
return {
|
||||
page,
|
||||
total,
|
||||
itemsPerPage: perPage,
|
||||
pagedItems,
|
||||
sortField,
|
||||
sortDirection,
|
||||
toggleSort
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
78
src/platform/workspace/composables/useWorkspaceOverview.ts
Normal file
78
src/platform/workspace/composables/useWorkspaceOverview.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
|
||||
import { formatRelativeTime } from '@/platform/workspace/utils/relativeTime'
|
||||
|
||||
export interface SnapshotRow {
|
||||
userName: string
|
||||
color: string
|
||||
lastActivity: string
|
||||
credits: number
|
||||
}
|
||||
|
||||
const SNAPSHOT_SIZE = 4
|
||||
|
||||
export function useWorkspaceOverview() {
|
||||
const { t } = useI18n()
|
||||
const store = useTeamWorkspaceStore()
|
||||
const { members } = storeToRefs(store)
|
||||
|
||||
const nextRenewal = new Date()
|
||||
nextRenewal.setDate(nextRenewal.getDate() + 20)
|
||||
const plan = {
|
||||
name: 'Team',
|
||||
priceCents: 32000,
|
||||
renewalLabel: nextRenewal.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const nextInvoiceCents = 32000
|
||||
|
||||
function activityLabel(member: WorkspaceMember): string {
|
||||
if (!member.lastActivity) return '—'
|
||||
return formatRelativeTime(member.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)
|
||||
})
|
||||
}
|
||||
|
||||
function toRow(member: WorkspaceMember): SnapshotRow {
|
||||
return {
|
||||
userName: member.name,
|
||||
color: userBadgeColor(member.name || member.email),
|
||||
lastActivity: activityLabel(member),
|
||||
credits: member.creditsUsedThisMonth ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
const topSpenders = computed(() =>
|
||||
[...members.value]
|
||||
.sort(
|
||||
(a, b) => (b.creditsUsedThisMonth ?? 0) - (a.creditsUsedThisMonth ?? 0)
|
||||
)
|
||||
.slice(0, SNAPSHOT_SIZE)
|
||||
.map(toRow)
|
||||
)
|
||||
|
||||
const recentActivity = computed(() =>
|
||||
[...members.value]
|
||||
.filter((member) => member.lastActivity)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(b.lastActivity?.getTime() ?? 0) - (a.lastActivity?.getTime() ?? 0)
|
||||
)
|
||||
.slice(0, SNAPSHOT_SIZE)
|
||||
.map(toRow)
|
||||
)
|
||||
|
||||
return { plan, nextInvoiceCents, topSpenders, recentActivity }
|
||||
}
|
||||
17
src/platform/workspace/composables/useWorkspaceRename.ts
Normal file
17
src/platform/workspace/composables/useWorkspaceRename.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Shared inline-rename state so both the header (double-click) and the
|
||||
// workspace menu ("Rename") drive the same editing affordance.
|
||||
const isRenaming = ref(false)
|
||||
|
||||
export function useWorkspaceRename() {
|
||||
function startRenaming() {
|
||||
isRenaming.value = true
|
||||
}
|
||||
|
||||
function stopRenaming() {
|
||||
isRenaming.value = false
|
||||
}
|
||||
|
||||
return { isRenaming, startRenaming, stopRenaming }
|
||||
}
|
||||
@@ -209,7 +209,7 @@ describe('useWorkspaceUI', () => {
|
||||
expect(ui.workspaceRole.value).toBe('member')
|
||||
expect(ui.permissions.value).toMatchObject({
|
||||
canViewOtherMembers: true,
|
||||
canViewPendingInvites: false,
|
||||
canViewPendingInvites: true,
|
||||
canInviteMembers: false,
|
||||
canManageInvites: false,
|
||||
canManageMembers: false,
|
||||
@@ -220,11 +220,11 @@ describe('useWorkspaceUI', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows members but hides invite management and uses leave action', async () => {
|
||||
it('shows members and pending but hides invite management and uses leave action', async () => {
|
||||
const ui = await loadComposable()
|
||||
|
||||
expect(ui.uiConfig.value.showMembersList).toBe(true)
|
||||
expect(ui.uiConfig.value.showPendingTab).toBe(false)
|
||||
expect(ui.uiConfig.value.showPendingTab).toBe(true)
|
||||
expect(ui.uiConfig.value.showEditWorkspaceMenuItem).toBe(false)
|
||||
expect(ui.uiConfig.value.workspaceMenuAction).toBe('leave')
|
||||
expect(ui.uiConfig.value.workspaceMenuDisabledTooltip).toBeNull()
|
||||
|
||||
@@ -23,6 +23,9 @@ interface WorkspacePermissions {
|
||||
// commit); only the original owner gets `canManageSubscriptionLifecycle`.
|
||||
canManageSubscriptionLifecycle: boolean
|
||||
canTopUp: boolean
|
||||
// Partner-node governance is workspace-wide and gated to Owner + Admins
|
||||
// (both hold the backend 'owner' role); Members never see the tab.
|
||||
canManagePartnerNodes: boolean
|
||||
}
|
||||
|
||||
/** UI configuration for workspace role */
|
||||
@@ -56,7 +59,8 @@ function getPermissions(
|
||||
canManageSubscription: true,
|
||||
// Personal workspace is single-member: the user is the sole owner/creator.
|
||||
canManageSubscriptionLifecycle: true,
|
||||
canTopUp: true
|
||||
canTopUp: true,
|
||||
canManagePartnerNodes: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,14 +75,17 @@ function getPermissions(
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: true,
|
||||
canManageSubscriptionLifecycle: isOriginalOwner,
|
||||
canTopUp: true
|
||||
canTopUp: true,
|
||||
canManagePartnerNodes: true
|
||||
}
|
||||
}
|
||||
|
||||
// member role
|
||||
return {
|
||||
canViewOtherMembers: true,
|
||||
canViewPendingInvites: false,
|
||||
// Members can see who's been invited (view-only); they still can't
|
||||
// resend/revoke (canManageInvites) or invite (canInviteMembers).
|
||||
canViewPendingInvites: true,
|
||||
canInviteMembers: false,
|
||||
canManageInvites: false,
|
||||
canManageMembers: false,
|
||||
@@ -86,7 +93,8 @@ function getPermissions(
|
||||
canAccessWorkspaceMenu: true,
|
||||
canManageSubscription: false,
|
||||
canManageSubscriptionLifecycle: false,
|
||||
canTopUp: false
|
||||
canTopUp: false,
|
||||
canManagePartnerNodes: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +136,7 @@ function getUIConfig(
|
||||
// member role
|
||||
return {
|
||||
showMembersList: true,
|
||||
showPendingTab: false,
|
||||
showPendingTab: true,
|
||||
showSearch: true,
|
||||
showRoleColumn: true,
|
||||
membersGridCols: 'grid-cols-[1fr_auto]',
|
||||
|
||||
2334
src/platform/workspace/dev/billingMockHarness.ts
Normal file
2334
src/platform/workspace/dev/billingMockHarness.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@ export interface WorkspaceMember {
|
||||
joinDate: Date
|
||||
role: 'owner' | 'member'
|
||||
isOriginalOwner: boolean
|
||||
lastActivity?: Date | null
|
||||
creditsUsedThisMonth?: number
|
||||
}
|
||||
|
||||
export interface PendingInvite {
|
||||
@@ -51,7 +53,11 @@ function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
|
||||
email: member.email,
|
||||
joinDate: new Date(member.joined_at),
|
||||
role: member.role,
|
||||
isOriginalOwner: member.is_original_owner ?? false
|
||||
isOriginalOwner: member.is_original_owner ?? false,
|
||||
lastActivity: member.last_active_at
|
||||
? new Date(member.last_active_at)
|
||||
: null,
|
||||
creditsUsedThisMonth: member.credits_used_this_month ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +110,7 @@ function setLastWorkspaceId(workspaceId: string): void {
|
||||
}
|
||||
|
||||
const MAX_OWNED_WORKSPACES = 10
|
||||
export const MAX_WORKSPACE_MEMBERS = 30
|
||||
export const MAX_WORKSPACE_MEMBERS = 50
|
||||
const MAX_INIT_RETRIES = 3
|
||||
const BASE_RETRY_DELAY_MS = 1000
|
||||
|
||||
|
||||
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]
|
||||
}
|
||||
41
src/platform/workspace/utils/relativeTime.test.ts
Normal file
41
src/platform/workspace/utils/relativeTime.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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-03T12:00:00Z')
|
||||
const ago = (ms: number) => new Date(now.getTime() - ms)
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
it('returns "just now" under a minute', () => {
|
||||
expect(formatRelativeTime(ago(30 * 1000), now, labels)).toBe('just now')
|
||||
})
|
||||
|
||||
it('floors to whole minutes', () => {
|
||||
expect(formatRelativeTime(ago(6.9 * 60 * 1000), now, labels)).toBe(
|
||||
'6 min ago'
|
||||
)
|
||||
})
|
||||
|
||||
it('floors to whole hours', () => {
|
||||
expect(formatRelativeTime(ago(2 * 60 * 60 * 1000), now, labels)).toBe(
|
||||
'2 hr ago'
|
||||
)
|
||||
})
|
||||
|
||||
it('floors to whole days past 24h', () => {
|
||||
expect(formatRelativeTime(ago(3 * 24 * 60 * 60 * 1000), now, labels)).toBe(
|
||||
'3 days ago'
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps future dates to "just now"', () => {
|
||||
expect(formatRelativeTime(ago(-5000), 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))
|
||||
}
|
||||
18
src/platform/workspace/utils/roleLabels.test.ts
Normal file
18
src/platform/workspace/utils/roleLabels.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { roleLabelKey } from '@/platform/workspace/utils/roleLabels'
|
||||
|
||||
describe('roleLabelKey', () => {
|
||||
it('labels the workspace creator as Owner', () => {
|
||||
expect(roleLabelKey('owner', true)).toBe('workspaceSwitcher.roleOwner')
|
||||
})
|
||||
|
||||
it('labels a non-creator owner-role member as Admin', () => {
|
||||
expect(roleLabelKey('owner', false)).toBe('workspaceSwitcher.roleAdmin')
|
||||
})
|
||||
|
||||
it('labels a member as Member regardless of creator flag', () => {
|
||||
expect(roleLabelKey('member', false)).toBe('workspaceSwitcher.roleMember')
|
||||
expect(roleLabelKey('member', true)).toBe('workspaceSwitcher.roleMember')
|
||||
})
|
||||
})
|
||||
23
src/platform/workspace/utils/roleLabels.ts
Normal file
23
src/platform/workspace/utils/roleLabels.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
export type RoleLabelKey =
|
||||
| 'workspaceSwitcher.roleOwner'
|
||||
| 'workspaceSwitcher.roleAdmin'
|
||||
| 'workspaceSwitcher.roleMember'
|
||||
|
||||
/**
|
||||
* Resolves the display label for a member's role.
|
||||
*
|
||||
* The backend role is `'owner' | 'member'`, where `'owner'` covers both the
|
||||
* workspace creator and elevated non-creators. V1 splits those visually:
|
||||
* the creator keeps "Owner"; every other `'owner'` becomes "Admin".
|
||||
*/
|
||||
export function roleLabelKey(
|
||||
role: WorkspaceRole,
|
||||
isOriginalOwner: boolean
|
||||
): RoleLabelKey {
|
||||
if (role === 'member') return 'workspaceSwitcher.roleMember'
|
||||
return isOriginalOwner
|
||||
? 'workspaceSwitcher.roleOwner'
|
||||
: 'workspaceSwitcher.roleAdmin'
|
||||
}
|
||||
@@ -8,3 +8,6 @@ export const WORKSPACE_STORAGE_KEYS = {
|
||||
} as const
|
||||
|
||||
export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000
|
||||
|
||||
/** Max length for a workspace name (create, rename, edit). */
|
||||
export const WORKSPACE_NAME_MAX_LENGTH = 30
|
||||
|
||||
@@ -10,6 +10,7 @@ import { t } from '@/i18n'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type {
|
||||
@@ -570,6 +571,18 @@ export const useDialogService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function showAutoReloadDialog() {
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/dialogs/AutoReloadDialogContent.vue')
|
||||
return dialogStore.showDialog({
|
||||
key: 'auto-reload',
|
||||
component,
|
||||
dialogComponentProps: {
|
||||
...workspaceDialogProps
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function showInviteMemberUpsellDialog() {
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/dialogs/InviteMemberUpsellDialogContent.vue')
|
||||
@@ -582,6 +595,48 @@ export const useDialogService = () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function showMemberLimitDialog(maxSeats: number) {
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/dialogs/RequestMoreDialogContent.vue')
|
||||
const { staticUrls } = useExternalLink()
|
||||
return dialogStore.showDialog({
|
||||
key: 'member-limit',
|
||||
component,
|
||||
props: {
|
||||
dialogKey: 'member-limit',
|
||||
title: t('workspacePanel.memberLimitDialog.title'),
|
||||
message: t('workspacePanel.memberLimitDialog.message', {
|
||||
count: maxSeats
|
||||
}),
|
||||
onRequestMore: () =>
|
||||
window.open(
|
||||
staticUrls.teamPlanRequests,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
},
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
})
|
||||
}
|
||||
|
||||
async function showWorkflowQueuedDialog() {
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/dialogs/RequestMoreDialogContent.vue')
|
||||
const { staticUrls } = useExternalLink()
|
||||
return dialogStore.showDialog({
|
||||
key: 'workflow-queued',
|
||||
component,
|
||||
props: {
|
||||
dialogKey: 'workflow-queued',
|
||||
title: t('workspacePanel.workflowQueuedDialog.title'),
|
||||
message: t('workspacePanel.workflowQueuedDialog.message'),
|
||||
onRequestMore: () =>
|
||||
window.open(staticUrls.discord, '_blank', 'noopener,noreferrer')
|
||||
},
|
||||
dialogComponentProps: workspaceDialogProps
|
||||
})
|
||||
}
|
||||
|
||||
async function showRevokeInviteDialog(inviteId: string) {
|
||||
const { default: component } =
|
||||
await import('@/platform/workspace/components/dialogs/RevokeInviteDialogContent.vue')
|
||||
@@ -731,7 +786,10 @@ export const useDialogService = () => {
|
||||
showChangeMemberRoleDialog,
|
||||
showRevokeInviteDialog,
|
||||
showInviteMemberDialog,
|
||||
showAutoReloadDialog,
|
||||
showInviteMemberUpsellDialog,
|
||||
showMemberLimitDialog,
|
||||
showWorkflowQueuedDialog,
|
||||
showBillingComingSoonDialog,
|
||||
showCancelSubscriptionDialog,
|
||||
showDowngradeToPersonalDialog
|
||||
|
||||
Reference in New Issue
Block a user