@ossy/workspaces 3.11.0 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/workspaces",
|
|
3
3
|
"description": "Workspaces feature package - create, select, and manage workspaces, users, and invitations",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.12.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"module": "./src/index.js",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"@ossy/design-system": ">=1.0.0",
|
|
36
36
|
"@ossy/router-react": ">=1.0.0",
|
|
37
37
|
"@ossy/sdk-react": ">=1.0.0",
|
|
38
|
+
"@ossy/users": ">=1.0.0",
|
|
38
39
|
"react": ">=19.0.0 <20.0.0"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
@@ -50,5 +51,5 @@
|
|
|
50
51
|
"/src",
|
|
51
52
|
"README.md"
|
|
52
53
|
],
|
|
53
|
-
"gitHead": "
|
|
54
|
+
"gitHead": "c2650803219ad1831559b512848358d9550ffad2"
|
|
54
55
|
}
|
package/src/GeneralSettings.jsx
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import React, { useState } from 'react'
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
2
|
import { GetWorkspace, EnableService, DisableService } from '@ossy/workspaces'
|
|
3
3
|
import { GetCurrentUser } from '@ossy/authentication'
|
|
4
4
|
import { useSdk, AsyncStatus, cacheKey } from '@ossy/sdk-react'
|
|
5
5
|
import { Switch, Button, View, Text, ContentHeader, useLocale } from '@ossy/design-system'
|
|
6
6
|
|
|
7
|
+
const GET_WORKSPACE_KPIS = { id: '@ossy/analytics/actions/get-workspace-kpis' }
|
|
8
|
+
|
|
7
9
|
const formatDate = (timestamp) => timestamp ? new Date(timestamp).toLocaleDateString() : ''
|
|
8
10
|
const FlowStage = { View: 'View', Edit: 'Edit' }
|
|
9
11
|
|
|
@@ -21,8 +23,30 @@ export const GeneralSettings = () => {
|
|
|
21
23
|
const { status: userStatus } = sdk.read(GetCurrentUser)
|
|
22
24
|
const [flowStage, setFlowStage] = useState(FlowStage.View)
|
|
23
25
|
const [busyPackage, setBusyPackage] = useState(null)
|
|
26
|
+
const [kpis, setKpis] = useState({ status: 'idle', members: 0, resources: 0 })
|
|
24
27
|
const services = workspace?.services || {}
|
|
25
28
|
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!workspace?.id) return undefined
|
|
31
|
+
let cancelled = false
|
|
32
|
+
sdk.invoke(GET_WORKSPACE_KPIS, {})
|
|
33
|
+
.then((json) => {
|
|
34
|
+
if (cancelled) return
|
|
35
|
+
setKpis({
|
|
36
|
+
status: 'success',
|
|
37
|
+
members: json?.members || 0,
|
|
38
|
+
resources: json?.resources || 0,
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
.catch(() => {
|
|
42
|
+
if (cancelled) return
|
|
43
|
+
setKpis((current) => ({ ...current, status: 'error' }))
|
|
44
|
+
})
|
|
45
|
+
return () => {
|
|
46
|
+
cancelled = true
|
|
47
|
+
}
|
|
48
|
+
}, [sdk, workspace?.id])
|
|
49
|
+
|
|
26
50
|
const toggleService = async (packageName, enable) => {
|
|
27
51
|
setBusyPackage(packageName)
|
|
28
52
|
try {
|
|
@@ -61,6 +85,12 @@ export const GeneralSettings = () => {
|
|
|
61
85
|
>
|
|
62
86
|
<View layout="row" gap="s"><Text style={{ fontWeight: 'bold' }} text="workspace.settings.name" /><Text>{workspace?.name}</Text></View>
|
|
63
87
|
<View layout="row" gap="s"><Text style={{ fontWeight: 'bold' }} text="workspace.settings.created" /><Text>{formatDate(workspace?.created)}</Text></View>
|
|
88
|
+
{kpis.status === 'success' && (
|
|
89
|
+
<>
|
|
90
|
+
<View layout="row" gap="s"><Text style={{ fontWeight: 'bold' }} text="workspace.settings.members" /><Text>{kpis.members}</Text></View>
|
|
91
|
+
<View layout="row" gap="s"><Text style={{ fontWeight: 'bold' }} text="workspace.settings.resources" /><Text>{kpis.resources}</Text></View>
|
|
92
|
+
</>
|
|
93
|
+
)}
|
|
64
94
|
{MANAGED_SERVICES.length > 0 && (
|
|
65
95
|
<>
|
|
66
96
|
<Text variant="heading-tertiary" as="h3" text="workspace.settings.services" />
|
package/src/en.translations.json
CHANGED
|
@@ -78,6 +78,8 @@
|
|
|
78
78
|
"workspace.settings.service.enable": "Enable",
|
|
79
79
|
"workspace.settings.service.disable": "Disable",
|
|
80
80
|
"workspace.settings.loading": "Loading...",
|
|
81
|
+
"workspace.settings.members": "Members",
|
|
82
|
+
"workspace.settings.resources": "Resources",
|
|
81
83
|
"workspace.invitations.description": "Invite users to your workspace to collaborate.",
|
|
82
84
|
"workspace.invitations.invite": "Invite user",
|
|
83
85
|
"workspace.invitations.empty": "No invitations",
|
package/src/invite-user.flow.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import signUpFlow from '@ossy/authentication/sign-up.flow.js'
|
|
2
|
+
import { CompleteUserName, CompleteUserNameForm } from '@ossy/users'
|
|
2
3
|
import { InviteUser } from '@ossy/workspaces'
|
|
3
4
|
import { metadata as InviteUserForm } from './invite-user.form.js'
|
|
4
5
|
|
|
@@ -10,12 +11,13 @@ export const metadata = {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
|
-
*
|
|
14
|
+
* Invite a teammate, accept via email, then complete the signed-in name gate
|
|
15
|
+
* (issue #419). Invitees are created email-only; product UI prompts for name.
|
|
14
16
|
*/
|
|
15
17
|
export default {
|
|
16
18
|
title: 'Invite and accept workspace invitation',
|
|
17
19
|
description:
|
|
18
|
-
'Signed-in user invites another email,
|
|
20
|
+
'Signed-in user invites another email, invitee accepts, sets name on the product gate, and sees that name on profile',
|
|
19
21
|
steps: [
|
|
20
22
|
...signUpFlow.steps,
|
|
21
23
|
// Keep the inviter address before the invite form overwrites `$email`.
|
|
@@ -36,5 +38,28 @@ export default {
|
|
|
36
38
|
},
|
|
37
39
|
{ result: { selector: '[data-flow-stage="invite-verify-success"]', timeout: 15000 } },
|
|
38
40
|
{ result: { page: '@profile/workspaces', timeout: 20000 } },
|
|
41
|
+
{ result: { selector: '[data-flow-stage="complete-user-name"]', timeout: 15000 } },
|
|
42
|
+
{
|
|
43
|
+
form: CompleteUserNameForm,
|
|
44
|
+
fields: {
|
|
45
|
+
firstName: 'Invitee',
|
|
46
|
+
lastName: 'Named',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{ action: CompleteUserName },
|
|
50
|
+
{
|
|
51
|
+
result: {
|
|
52
|
+
selector: '[data-flow-stage="complete-user-name"]',
|
|
53
|
+
hidden: true,
|
|
54
|
+
timeout: 15000,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{ page: '@profile' },
|
|
58
|
+
{
|
|
59
|
+
result: {
|
|
60
|
+
selector: '[data-user-name="Invitee Named"]',
|
|
61
|
+
timeout: 15000,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
39
64
|
],
|
|
40
65
|
}
|
package/src/sv.translations.json
CHANGED
|
@@ -78,6 +78,8 @@
|
|
|
78
78
|
"workspace.settings.service.enable": "Aktivera",
|
|
79
79
|
"workspace.settings.service.disable": "Inaktivera",
|
|
80
80
|
"workspace.settings.loading": "Laddar...",
|
|
81
|
+
"workspace.settings.members": "Medlemmar",
|
|
82
|
+
"workspace.settings.resources": "Resurser",
|
|
81
83
|
"workspace.invitations.description": "Bjud in användare till ditt workspace för att samarbeta.",
|
|
82
84
|
"workspace.invitations.invite": "Bjud in användare",
|
|
83
85
|
"workspace.invitations.empty": "Inga inbjudningar",
|
|
@@ -4,42 +4,78 @@ import { WorkspaceSchema } from './schema-ids.js'
|
|
|
4
4
|
|
|
5
5
|
const log = createLogger('workspaces')
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Membership events that mean the user belongs to a workspace.
|
|
9
|
+
* Kept narrow so the list query never scans every workspace stream.
|
|
10
|
+
*/
|
|
11
|
+
const MEMBERSHIP_EVENTS = ['Created', 'UserInvitationAccepted']
|
|
12
|
+
|
|
7
13
|
export class WorkspacesQueries {
|
|
14
|
+
/**
|
|
15
|
+
* Minified `{ id, name }[]` for workspaces the user created or joined.
|
|
16
|
+
*
|
|
17
|
+
* Pipeline shape (perf-critical for select-workspace):
|
|
18
|
+
* 1. `$match` only this user's membership events (not every workspace event)
|
|
19
|
+
* 2. `$group` by workspace id (name when the user is the creator)
|
|
20
|
+
* 3. `$lookup` the workspace `Created` event for name when the user only joined
|
|
21
|
+
*/
|
|
8
22
|
static getAllByUserIncluded (userId) {
|
|
9
23
|
log.info('[WorkspacesQueries] getAllByUserIncluded()')
|
|
24
|
+
if (!userId) return Promise.resolve([])
|
|
25
|
+
|
|
10
26
|
return EventStore.Aggregate([
|
|
11
|
-
{ $match: { type: WorkspaceSchema.workspace, resourceId: { $exists: true } } },
|
|
12
|
-
{ $group: { _id: '$resourceId', events: { $push: '$$ROOT' } } },
|
|
13
27
|
{
|
|
14
28
|
$match: {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
],
|
|
19
|
-
'events.event': { $in: ['Created', 'UserInvitationAccepted'] },
|
|
29
|
+
type: WorkspaceSchema.workspace,
|
|
30
|
+
createdBy: userId,
|
|
31
|
+
event: { $in: MEMBERSHIP_EVENTS },
|
|
20
32
|
},
|
|
21
33
|
},
|
|
22
34
|
{
|
|
23
|
-
$
|
|
24
|
-
|
|
35
|
+
$group: {
|
|
36
|
+
_id: '$resourceId',
|
|
25
37
|
name: {
|
|
26
|
-
$
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
$max: {
|
|
39
|
+
$cond: [
|
|
40
|
+
{ $eq: ['$event', 'Created'] },
|
|
41
|
+
'$payload.name',
|
|
42
|
+
null,
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
$lookup: {
|
|
50
|
+
from: 'eventstore',
|
|
51
|
+
let: { workspaceId: '$_id' },
|
|
52
|
+
pipeline: [
|
|
53
|
+
{
|
|
54
|
+
$match: {
|
|
55
|
+
$expr: {
|
|
56
|
+
$and: [
|
|
57
|
+
{ $eq: ['$resourceId', '$$workspaceId'] },
|
|
58
|
+
{ $eq: ['$type', WorkspaceSchema.workspace] },
|
|
59
|
+
{ $eq: ['$event', 'Created'] },
|
|
38
60
|
],
|
|
39
61
|
},
|
|
40
62
|
},
|
|
41
|
-
in: '$$created.payload.name',
|
|
42
63
|
},
|
|
64
|
+
{ $limit: 1 },
|
|
65
|
+
{ $project: { _id: 0, name: '$payload.name' } },
|
|
66
|
+
],
|
|
67
|
+
as: '_created',
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
$project: {
|
|
72
|
+
_id: 0,
|
|
73
|
+
id: '$_id',
|
|
74
|
+
name: {
|
|
75
|
+
$ifNull: [
|
|
76
|
+
'$name',
|
|
77
|
+
{ $arrayElemAt: ['$_created.name', 0] },
|
|
78
|
+
],
|
|
43
79
|
},
|
|
44
80
|
},
|
|
45
81
|
},
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
|
2
|
+
|
|
3
|
+
const aggregateMock = jest.fn()
|
|
4
|
+
|
|
5
|
+
jest.unstable_mockModule('@ossy/event-store', () => ({
|
|
6
|
+
EventStore: {
|
|
7
|
+
Aggregate: aggregateMock,
|
|
8
|
+
},
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
jest.unstable_mockModule('@ossy/observability', () => ({
|
|
12
|
+
createLogger: () => ({
|
|
13
|
+
info: () => {},
|
|
14
|
+
error: () => {},
|
|
15
|
+
warn: () => {},
|
|
16
|
+
debug: () => {},
|
|
17
|
+
}),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
const { WorkspacesQueries } = await import('./workspaces.queries.js')
|
|
21
|
+
const { WorkspaceSchema } = await import('./schema-ids.js')
|
|
22
|
+
|
|
23
|
+
describe('WorkspacesQueries.getAllByUserIncluded', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
aggregateMock.mockReset()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('returns [] without hitting Mongo when userId is missing', async () => {
|
|
29
|
+
await expect(WorkspacesQueries.getAllByUserIncluded()).resolves.toEqual([])
|
|
30
|
+
await expect(WorkspacesQueries.getAllByUserIncluded(null)).resolves.toEqual([])
|
|
31
|
+
expect(aggregateMock).not.toHaveBeenCalled()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('scopes the aggregation to this user membership events only', async () => {
|
|
35
|
+
aggregateMock.mockResolvedValue([{ id: 'ws-1', name: 'Acme' }])
|
|
36
|
+
|
|
37
|
+
await expect(WorkspacesQueries.getAllByUserIncluded('user-1')).resolves.toEqual([
|
|
38
|
+
{ id: 'ws-1', name: 'Acme' },
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
expect(aggregateMock).toHaveBeenCalledTimes(1)
|
|
42
|
+
const [pipeline] = aggregateMock.mock.calls[0]
|
|
43
|
+
expect(pipeline[0]).toEqual({
|
|
44
|
+
$match: {
|
|
45
|
+
type: WorkspaceSchema.workspace,
|
|
46
|
+
createdBy: 'user-1',
|
|
47
|
+
event: { $in: ['Created', 'UserInvitationAccepted'] },
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
expect(pipeline.some(stage => stage.$lookup?.from === 'eventstore')).toBe(true)
|
|
51
|
+
// Must not scan every workspace stream then filter
|
|
52
|
+
expect(JSON.stringify(pipeline[0])).not.toContain('events.createdBy')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('returns [] when the aggregation rejects', async () => {
|
|
56
|
+
aggregateMock.mockRejectedValue(new Error('mongo down'))
|
|
57
|
+
await expect(WorkspacesQueries.getAllByUserIncluded('user-1')).resolves.toEqual([])
|
|
58
|
+
})
|
|
59
|
+
})
|