@csntgao/uni-base 0.1.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/.prettierignore +4 -0
- package/.prettierrc.json +6 -0
- package/AGENTS.md +519 -0
- package/CLAUDE.md +512 -0
- package/README.md +734 -0
- package/docs//346/225/260/345/255/227/345/221/230/345/267/245/345/256/214/346/225/264/346/212/275/345/261/211/346/216/245/345/205/245/350/257/264/346/230/216.md +110 -0
- package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/215/225/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +79 -0
- package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/244/232/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +93 -0
- package/docs//350/264/246/346/210/267/344/270/216/347/231/273/345/275/225/347/273/204/344/273/266/345/212/250/346/200/201/346/216/245/345/205/245/350/257/264/346/230/216.md +201 -0
- package/eslint.config.js +30 -0
- package/examples/vanilla/index.html +33 -0
- package/examples/vanilla/main.js +247 -0
- package/examples/vanilla/package.json +15 -0
- package/examples/vanilla/public/runtime-config.js +10 -0
- package/examples/vanilla/style.css +99 -0
- package/package.json +31 -0
- package/packages/core/package.json +30 -0
- package/packages/core/src/client.ts +1132 -0
- package/packages/core/src/customer-support-client.ts +825 -0
- package/packages/core/src/customer-support-types.ts +123 -0
- package/packages/core/src/index.ts +79 -0
- package/packages/core/src/notification-center-client.ts +462 -0
- package/packages/core/src/notification-center-types.ts +154 -0
- package/packages/core/src/protocol.ts +237 -0
- package/packages/core/src/types.ts +170 -0
- package/packages/core/tests/client.test.ts +760 -0
- package/packages/core/tests/customer-support-client.test.ts +576 -0
- package/packages/core/tests/fake-websocket.ts +55 -0
- package/packages/core/tests/notification-center-client.test.ts +467 -0
- package/packages/core/tests/protocol.test.ts +124 -0
- package/packages/core/tsconfig.json +7 -0
- package/packages/core/tsup.config.ts +11 -0
- package/packages/web-components/package.json +35 -0
- package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
- package/packages/web-components/src/account-context.ts +1540 -0
- package/packages/web-components/src/application-switcher-transport.ts +243 -0
- package/packages/web-components/src/application-switcher.ts +1103 -0
- package/packages/web-components/src/chat.ts +1754 -0
- package/packages/web-components/src/customer-support-chat.ts +862 -0
- package/packages/web-components/src/customer-support-transport.ts +269 -0
- package/packages/web-components/src/index.ts +256 -0
- package/packages/web-components/src/notification-center.ts +1066 -0
- package/packages/web-components/src/notification-popover.ts +615 -0
- package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
- package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
- package/packages/web-components/src/organization-onboarding.ts +1216 -0
- package/packages/web-components/src/organization-selector.ts +534 -0
- package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
- package/packages/web-components/src/prototype-icons.ts +242 -0
- package/packages/web-components/src/safe-image-url.ts +29 -0
- package/packages/web-components/src/safe-markdown.ts +192 -0
- package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
- package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
- package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
- package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
- package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
- package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
- package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
- package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
- package/packages/web-components/src/user-login-transport.ts +563 -0
- package/packages/web-components/src/user-login.ts +2027 -0
- package/packages/web-components/src/user-menu.ts +880 -0
- package/packages/web-components/tests/account-context.test.ts +798 -0
- package/packages/web-components/tests/application-switcher.test.ts +562 -0
- package/packages/web-components/tests/chat.test.ts +1130 -0
- package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
- package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
- package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
- package/packages/web-components/tests/notification-center.test.ts +429 -0
- package/packages/web-components/tests/notification-popover.test.ts +283 -0
- package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
- package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
- package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
- package/packages/web-components/tests/organization-selector.test.ts +127 -0
- package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
- package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
- package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
- package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
- package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
- package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
- package/packages/web-components/tests/user-login-transport.test.ts +443 -0
- package/packages/web-components/tests/user-login.test.ts +626 -0
- package/packages/web-components/tests/user-menu.test.ts +271 -0
- package/packages/web-components/tsconfig.json +8 -0
- package/packages/web-components/tsup.config.ts +12 -0
- package/packages/web-components/tsup.runtime.config.ts +37 -0
- package/pnpm-workspace.yaml +6 -0
- package/tsconfig.base.json +21 -0
- package/vitest.config.ts +20 -0
- package//344/272/244/346/216/245/346/226/207/346/241/243.md +254 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import {
|
|
5
|
+
ORGANIZATION_MULTI_EMPLOYEE_SELECTOR_TAG,
|
|
6
|
+
UniplatOrganizationMultiEmployeeSelectorElement,
|
|
7
|
+
type OrganizationEmployeeSelectionItem,
|
|
8
|
+
type UniplatOrganizationMultiEmployeeSelectorTransport,
|
|
9
|
+
} from '../src'
|
|
10
|
+
|
|
11
|
+
function employee(
|
|
12
|
+
employeeId: string,
|
|
13
|
+
displayName: string,
|
|
14
|
+
departmentName = '研发部',
|
|
15
|
+
): OrganizationEmployeeSelectionItem {
|
|
16
|
+
return {
|
|
17
|
+
employeeId,
|
|
18
|
+
displayName,
|
|
19
|
+
maskedMobile: '138****6688',
|
|
20
|
+
primaryDepartmentId: '10',
|
|
21
|
+
primaryDepartmentName: departmentName,
|
|
22
|
+
departmentIds: ['10'],
|
|
23
|
+
departmentNames: [departmentName],
|
|
24
|
+
employmentStatus: 'active',
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function transport(
|
|
29
|
+
overrides: Partial<UniplatOrganizationMultiEmployeeSelectorTransport> = {},
|
|
30
|
+
): UniplatOrganizationMultiEmployeeSelectorTransport {
|
|
31
|
+
return {
|
|
32
|
+
loadDepartmentTree: vi.fn(async () => [
|
|
33
|
+
{ id: '10', name: '研发部', depth: 0, memberCount: 2 },
|
|
34
|
+
{ id: '11', name: '前端组', parentId: '10', depth: 1, memberCount: 1 },
|
|
35
|
+
]),
|
|
36
|
+
listDepartmentEmployees: vi.fn(async ({ page, pageSize }) => ({
|
|
37
|
+
items: [employee('2', '李四', '前端组')],
|
|
38
|
+
page,
|
|
39
|
+
pageSize,
|
|
40
|
+
total: 1,
|
|
41
|
+
hasMore: false,
|
|
42
|
+
})),
|
|
43
|
+
searchEmployees: vi.fn(async ({ page, pageSize }) => ({
|
|
44
|
+
items: [employee('1', '张三'), employee('2', '李四', '前端组')],
|
|
45
|
+
page,
|
|
46
|
+
pageSize,
|
|
47
|
+
total: 2,
|
|
48
|
+
hasMore: false,
|
|
49
|
+
})),
|
|
50
|
+
resolveEmployees: vi.fn(async ({ employeeIds }) =>
|
|
51
|
+
employeeIds.map((id: string) => employee(id, id === '1' ? '张三' : '李四')),
|
|
52
|
+
),
|
|
53
|
+
...overrides,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function createSelector(
|
|
58
|
+
selectorTransport = transport(),
|
|
59
|
+
): Promise<UniplatOrganizationMultiEmployeeSelectorElement> {
|
|
60
|
+
const selector = document.createElement(ORGANIZATION_MULTI_EMPLOYEE_SELECTOR_TAG)
|
|
61
|
+
selector.configure({ applicationName: 'OfficialWebsite', transport: selectorTransport })
|
|
62
|
+
document.body.append(selector)
|
|
63
|
+
return selector
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function settle(selector: UniplatOrganizationMultiEmployeeSelectorElement): Promise<void> {
|
|
67
|
+
await selector.updateComplete
|
|
68
|
+
await Promise.resolve()
|
|
69
|
+
await selector.updateComplete
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('<uniplat-organization-multi-employee-selector>', () => {
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
document.body.replaceChildren()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('registers the public element and renders a modal department and employee selector', async () => {
|
|
78
|
+
expect(customElements.get(ORGANIZATION_MULTI_EMPLOYEE_SELECTOR_TAG)).toBe(
|
|
79
|
+
UniplatOrganizationMultiEmployeeSelectorElement,
|
|
80
|
+
)
|
|
81
|
+
const selector = await createSelector()
|
|
82
|
+
await selector.open({ title: '选择组织管理员' })
|
|
83
|
+
await settle(selector)
|
|
84
|
+
|
|
85
|
+
expect(selector.shadowRoot?.querySelector('[role="dialog"]')).toBeTruthy()
|
|
86
|
+
expect(selector.shadowRoot?.textContent).toContain('选择组织管理员')
|
|
87
|
+
expect(selector.shadowRoot?.textContent).toContain('组织架构')
|
|
88
|
+
expect(selector.shadowRoot?.textContent).toContain('研发部')
|
|
89
|
+
expect(selector.shadowRoot?.querySelectorAll('.employee')).toHaveLength(2)
|
|
90
|
+
expect(selector.shadowRoot?.textContent).toContain('已选择的成员')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('resolves initial selections, de-duplicates ids and keeps selections across filters', async () => {
|
|
94
|
+
const selectorTransport = transport()
|
|
95
|
+
const selector = await createSelector(selectorTransport)
|
|
96
|
+
await selector.open({ initialSelectedEmployeeIds: ['1', '1'] })
|
|
97
|
+
await settle(selector)
|
|
98
|
+
|
|
99
|
+
expect(selectorTransport.resolveEmployees).toHaveBeenCalledWith({ employeeIds: ['1'] })
|
|
100
|
+
expect(selector.shadowRoot?.querySelectorAll('.selected-employee')).toHaveLength(1)
|
|
101
|
+
expect(selector.shadowRoot?.textContent).toContain('张三')
|
|
102
|
+
|
|
103
|
+
selector.shadowRoot?.querySelectorAll<HTMLButtonElement>('.department')[1]?.click()
|
|
104
|
+
await settle(selector)
|
|
105
|
+
await Promise.resolve()
|
|
106
|
+
await settle(selector)
|
|
107
|
+
expect(selector.shadowRoot?.querySelectorAll('.selected-employee')).toHaveLength(1)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('enforces maximum selection and protected selections', async () => {
|
|
111
|
+
const selector = await createSelector()
|
|
112
|
+
await selector.open({
|
|
113
|
+
initialSelectedEmployeeIds: ['1'],
|
|
114
|
+
disabledSelections: [{ employeeId: '1', reason: '组织所有者不能移除' }],
|
|
115
|
+
maxSelection: 2,
|
|
116
|
+
})
|
|
117
|
+
await settle(selector)
|
|
118
|
+
|
|
119
|
+
selector.shadowRoot?.querySelectorAll<HTMLButtonElement>('.employee')[0]?.click()
|
|
120
|
+
await settle(selector)
|
|
121
|
+
expect(selector.shadowRoot?.textContent).toContain('组织所有者不能移除')
|
|
122
|
+
|
|
123
|
+
selector.shadowRoot?.querySelectorAll<HTMLButtonElement>('.employee')[1]?.click()
|
|
124
|
+
await settle(selector)
|
|
125
|
+
const selected = selector.shadowRoot?.querySelectorAll('.selected-employee')
|
|
126
|
+
expect(selected).toHaveLength(2)
|
|
127
|
+
expect(selected?.[0]?.querySelector<HTMLButtonElement>('.remove')?.disabled).toBe(true)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('emits only normalized employee summaries after explicit confirmation', async () => {
|
|
131
|
+
const selector = await createSelector()
|
|
132
|
+
const confirmed = vi.fn()
|
|
133
|
+
const cancelled = vi.fn()
|
|
134
|
+
selector.addEventListener('selection-confirmed', confirmed)
|
|
135
|
+
selector.addEventListener('selection-cancelled', cancelled)
|
|
136
|
+
await selector.open({ initialSelectedEmployeeIds: ['1'] })
|
|
137
|
+
await settle(selector)
|
|
138
|
+
|
|
139
|
+
selector.shadowRoot?.querySelector<HTMLButtonElement>('.primary')?.click()
|
|
140
|
+
await settle(selector)
|
|
141
|
+
const event = confirmed.mock.calls[0]?.[0]
|
|
142
|
+
expect(event.detail).toEqual({ employeeIds: ['1'], employees: [employee('1', '张三')] })
|
|
143
|
+
expect(event.bubbles).toBe(true)
|
|
144
|
+
expect(event.composed).toBe(true)
|
|
145
|
+
expect(cancelled).not.toHaveBeenCalled()
|
|
146
|
+
expect(JSON.stringify(event.detail)).not.toContain('token')
|
|
147
|
+
expect(selector.shadowRoot?.querySelector('[role="dialog"]')).toBeNull()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('cancels on Escape and restores a fresh state on the next open', async () => {
|
|
151
|
+
const selector = await createSelector()
|
|
152
|
+
const cancelled = vi.fn()
|
|
153
|
+
selector.addEventListener('selection-cancelled', cancelled)
|
|
154
|
+
await selector.open({ initialSelectedEmployeeIds: ['1'] })
|
|
155
|
+
await settle(selector)
|
|
156
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
157
|
+
await settle(selector)
|
|
158
|
+
expect(cancelled).toHaveBeenCalledTimes(1)
|
|
159
|
+
|
|
160
|
+
await selector.open()
|
|
161
|
+
await settle(selector)
|
|
162
|
+
expect(selector.shadowRoot?.querySelectorAll('.selected-employee')).toHaveLength(0)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('searches and paginates without losing selected employees', async () => {
|
|
166
|
+
const searchEmployees = vi
|
|
167
|
+
.fn<UniplatOrganizationMultiEmployeeSelectorTransport['searchEmployees']>()
|
|
168
|
+
.mockImplementation(async ({ page, pageSize, keyword }) => ({
|
|
169
|
+
items: page === 1 ? [employee('1', keyword ? '张三' : '张三')] : [employee('2', '李四')],
|
|
170
|
+
page,
|
|
171
|
+
pageSize,
|
|
172
|
+
total: 2,
|
|
173
|
+
hasMore: page === 1,
|
|
174
|
+
}))
|
|
175
|
+
const selector = await createSelector(transport({ searchEmployees }))
|
|
176
|
+
await selector.open()
|
|
177
|
+
await settle(selector)
|
|
178
|
+
selector.shadowRoot?.querySelector<HTMLButtonElement>('.employee')?.click()
|
|
179
|
+
|
|
180
|
+
selector.shadowRoot?.querySelector<HTMLButtonElement>('.load-more')?.click()
|
|
181
|
+
await Promise.resolve()
|
|
182
|
+
await settle(selector)
|
|
183
|
+
expect(selector.shadowRoot?.querySelectorAll('.employee')).toHaveLength(2)
|
|
184
|
+
expect(selector.shadowRoot?.querySelectorAll('.selected-employee')).toHaveLength(1)
|
|
185
|
+
|
|
186
|
+
const input = selector.shadowRoot?.querySelector<HTMLInputElement>('.search-row input')
|
|
187
|
+
if (!input) throw new Error('Search input is missing')
|
|
188
|
+
input.value = '张'
|
|
189
|
+
input.dispatchEvent(new InputEvent('input', { bubbles: true }))
|
|
190
|
+
selector.shadowRoot
|
|
191
|
+
?.querySelector<HTMLFormElement>('.search-row')
|
|
192
|
+
?.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true }))
|
|
193
|
+
await Promise.resolve()
|
|
194
|
+
await settle(selector)
|
|
195
|
+
expect(searchEmployees).toHaveBeenLastCalledWith(
|
|
196
|
+
expect.objectContaining({ keyword: '张', page: 1 }),
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('shows a safe retryable error without clearing an existing selection', async () => {
|
|
201
|
+
const selectorTransport = transport({
|
|
202
|
+
searchEmployees: vi.fn().mockRejectedValue(new Error('backend stack with secret-token')),
|
|
203
|
+
})
|
|
204
|
+
const selector = await createSelector(selectorTransport)
|
|
205
|
+
const errors = vi.fn()
|
|
206
|
+
selector.addEventListener('selector-error', errors)
|
|
207
|
+
await selector.open({ initialSelectedEmployeeIds: ['1'] })
|
|
208
|
+
await settle(selector)
|
|
209
|
+
|
|
210
|
+
expect(selector.shadowRoot?.textContent).toContain('组织成员加载失败,请稍后重试。')
|
|
211
|
+
expect(selector.shadowRoot?.textContent).not.toContain('secret-token')
|
|
212
|
+
expect(selector.shadowRoot?.querySelectorAll('.selected-employee')).toHaveLength(1)
|
|
213
|
+
expect(errors.mock.calls[0]?.[0].detail).toEqual({
|
|
214
|
+
code: 'UNKNOWN',
|
|
215
|
+
message: '组织成员加载失败,请稍后重试。',
|
|
216
|
+
retryable: true,
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('rejects unsupported single-select-like limits and department bulk selection', async () => {
|
|
221
|
+
const selector = await createSelector()
|
|
222
|
+
expect(() => selector.open({ maxSelection: 1 })).toThrow(TypeError)
|
|
223
|
+
expect(() => selector.open({ enableDepartmentBulkSelection: true })).toThrow(TypeError)
|
|
224
|
+
})
|
|
225
|
+
})
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { OrganizationOnboardingTransportError, type OrganizationOnboardingTransport } from '../src'
|
|
5
|
+
import {
|
|
6
|
+
INTERNAL_ORGANIZATION_ONBOARDING_TAG,
|
|
7
|
+
InternalOrganizationOnboarding,
|
|
8
|
+
} from '../src/organization-onboarding'
|
|
9
|
+
|
|
10
|
+
function createTransport(
|
|
11
|
+
overrides: Partial<OrganizationOnboardingTransport> = {},
|
|
12
|
+
): OrganizationOnboardingTransport {
|
|
13
|
+
return {
|
|
14
|
+
createEnterprise: vi.fn(async (request) => ({
|
|
15
|
+
id: 'enterprise-1',
|
|
16
|
+
name: request.organizationName,
|
|
17
|
+
type: 'enterprise' as const,
|
|
18
|
+
})),
|
|
19
|
+
createTeam: vi.fn(async (request) => ({
|
|
20
|
+
id: 'team-1',
|
|
21
|
+
name: request.teamName,
|
|
22
|
+
type: 'team' as const,
|
|
23
|
+
})),
|
|
24
|
+
...overrides,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function settle(onboarding: InternalOrganizationOnboarding): Promise<void> {
|
|
29
|
+
await Promise.resolve()
|
|
30
|
+
await onboarding.updateComplete
|
|
31
|
+
await Promise.resolve()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buttonByText(onboarding: InternalOrganizationOnboarding, text: string): HTMLButtonElement {
|
|
35
|
+
const button = [...(onboarding.shadowRoot?.querySelectorAll('button') ?? [])].find((candidate) =>
|
|
36
|
+
candidate.textContent?.includes(text),
|
|
37
|
+
)
|
|
38
|
+
if (!button) throw new Error(`Missing button: ${text}`)
|
|
39
|
+
return button
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function updateInput(
|
|
43
|
+
onboarding: InternalOrganizationOnboarding,
|
|
44
|
+
selector: string,
|
|
45
|
+
value: string,
|
|
46
|
+
): void {
|
|
47
|
+
const input = onboarding.shadowRoot?.querySelector<HTMLInputElement>(selector)
|
|
48
|
+
if (!input) throw new Error(`Missing input: ${selector}`)
|
|
49
|
+
input.value = value
|
|
50
|
+
input.dispatchEvent(new InputEvent('input', { bubbles: true }))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function submit(onboarding: InternalOrganizationOnboarding): void {
|
|
54
|
+
const form = onboarding.shadowRoot?.querySelector<HTMLFormElement>('form')
|
|
55
|
+
if (!form) throw new Error('Missing onboarding form')
|
|
56
|
+
form.dispatchEvent(new SubmitEvent('submit', { bubbles: true, cancelable: true }))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('internal organization onboarding', () => {
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
document.body.replaceChildren()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('is only registered under the internal account-context tag and renders the prototype choices', async () => {
|
|
65
|
+
expect(customElements.get('uniplat-organization-onboarding')).toBeUndefined()
|
|
66
|
+
expect(customElements.get(INTERNAL_ORGANIZATION_ONBOARDING_TAG)).toBe(
|
|
67
|
+
InternalOrganizationOnboarding,
|
|
68
|
+
)
|
|
69
|
+
const onboarding = document.createElement(INTERNAL_ORGANIZATION_ONBOARDING_TAG)
|
|
70
|
+
onboarding.configure({ transport: createTransport() })
|
|
71
|
+
document.body.append(onboarding)
|
|
72
|
+
await settle(onboarding)
|
|
73
|
+
|
|
74
|
+
expect(onboarding.shadowRoot?.querySelector('[part~="shell"]')).not.toBeNull()
|
|
75
|
+
expect(onboarding.shadowRoot?.textContent).toContain('创建你的团队 / 企业')
|
|
76
|
+
expect(onboarding.shadowRoot?.textContent).toContain('创建企业')
|
|
77
|
+
expect(onboarding.shadowRoot?.textContent).toContain('创建团队')
|
|
78
|
+
expect(onboarding.shadowRoot?.textContent).not.toContain('加入已有团队 / 企业')
|
|
79
|
+
expect(onboarding.shadowRoot?.querySelectorAll('.option')).toHaveLength(2)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('validates the enterprise form and creates an unverified enterprise', async () => {
|
|
83
|
+
const transport = createTransport()
|
|
84
|
+
const onboarding = document.createElement(INTERNAL_ORGANIZATION_ONBOARDING_TAG)
|
|
85
|
+
const created = vi.fn()
|
|
86
|
+
onboarding.addEventListener('organization-created', created)
|
|
87
|
+
onboarding.configure({ transport })
|
|
88
|
+
document.body.append(onboarding)
|
|
89
|
+
await settle(onboarding)
|
|
90
|
+
|
|
91
|
+
buttonByText(onboarding, '下一步').click()
|
|
92
|
+
await settle(onboarding)
|
|
93
|
+
submit(onboarding)
|
|
94
|
+
await settle(onboarding)
|
|
95
|
+
|
|
96
|
+
expect(onboarding.shadowRoot?.textContent).toContain('有 3 项信息需要修改')
|
|
97
|
+
expect(onboarding.shadowRoot?.textContent).toContain('格式有误,应为 18 位数字与大写字母')
|
|
98
|
+
|
|
99
|
+
updateInput(onboarding, '#organization-name', '星河科技')
|
|
100
|
+
updateInput(onboarding, '#enterprise-name', '北京星河科技有限公司')
|
|
101
|
+
updateInput(onboarding, '#credit-code', '91110108MA01ABC123')
|
|
102
|
+
submit(onboarding)
|
|
103
|
+
await vi.waitFor(() => expect(transport.createEnterprise).toHaveBeenCalledTimes(1))
|
|
104
|
+
await settle(onboarding)
|
|
105
|
+
|
|
106
|
+
expect(transport.createEnterprise).toHaveBeenCalledWith({
|
|
107
|
+
organizationName: '星河科技',
|
|
108
|
+
enterpriseName: '北京星河科技有限公司',
|
|
109
|
+
unifiedSocialCreditCode: '91110108MA01ABC123',
|
|
110
|
+
})
|
|
111
|
+
expect(onboarding.shadowRoot?.textContent).toContain('星河科技 已创建')
|
|
112
|
+
expect(onboarding.shadowRoot?.textContent).toContain('未认证')
|
|
113
|
+
expect(created.mock.calls[0]?.[0].detail).toEqual({
|
|
114
|
+
organizationId: 'enterprise-1',
|
|
115
|
+
type: 'enterprise',
|
|
116
|
+
})
|
|
117
|
+
expect(JSON.stringify(created.mock.calls[0]?.[0].detail)).not.toContain('91110108')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('maps duplicate enterprise and team responses to the prototype field errors', async () => {
|
|
121
|
+
const enterpriseOnboarding = document.createElement(INTERNAL_ORGANIZATION_ONBOARDING_TAG)
|
|
122
|
+
enterpriseOnboarding.configure({
|
|
123
|
+
transport: createTransport({
|
|
124
|
+
createEnterprise: vi.fn(async () => {
|
|
125
|
+
throw new OrganizationOnboardingTransportError('ENTERPRISE_NAME_CONFLICT')
|
|
126
|
+
}),
|
|
127
|
+
}),
|
|
128
|
+
})
|
|
129
|
+
document.body.append(enterpriseOnboarding)
|
|
130
|
+
await settle(enterpriseOnboarding)
|
|
131
|
+
buttonByText(enterpriseOnboarding, '下一步').click()
|
|
132
|
+
await settle(enterpriseOnboarding)
|
|
133
|
+
updateInput(enterpriseOnboarding, '#organization-name', '星河科技')
|
|
134
|
+
updateInput(enterpriseOnboarding, '#enterprise-name', '北京星河科技有限公司')
|
|
135
|
+
updateInput(enterpriseOnboarding, '#credit-code', '91110108MA01ABC123')
|
|
136
|
+
submit(enterpriseOnboarding)
|
|
137
|
+
await vi.waitFor(() =>
|
|
138
|
+
expect(enterpriseOnboarding.shadowRoot?.textContent).toContain('该企业已被创建'),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const joinRequested = vi.fn()
|
|
142
|
+
enterpriseOnboarding.addEventListener('join-organization-requested', joinRequested)
|
|
143
|
+
buttonByText(enterpriseOnboarding, '申请加入').click()
|
|
144
|
+
expect(joinRequested.mock.calls[0]?.[0].detail).toEqual({
|
|
145
|
+
enterpriseName: '北京星河科技有限公司',
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const teamOnboarding = document.createElement(INTERNAL_ORGANIZATION_ONBOARDING_TAG)
|
|
149
|
+
teamOnboarding.configure({
|
|
150
|
+
transport: createTransport({
|
|
151
|
+
createTeam: vi.fn(async () => {
|
|
152
|
+
throw new OrganizationOnboardingTransportError('TEAM_NAME_CONFLICT')
|
|
153
|
+
}),
|
|
154
|
+
}),
|
|
155
|
+
})
|
|
156
|
+
document.body.append(teamOnboarding)
|
|
157
|
+
await settle(teamOnboarding)
|
|
158
|
+
buttonByText(teamOnboarding, '创建团队').click()
|
|
159
|
+
buttonByText(teamOnboarding, '下一步').click()
|
|
160
|
+
await settle(teamOnboarding)
|
|
161
|
+
updateInput(teamOnboarding, '#team-name', '星河增长项目组')
|
|
162
|
+
submit(teamOnboarding)
|
|
163
|
+
await vi.waitFor(() =>
|
|
164
|
+
expect(teamOnboarding.shadowRoot?.textContent).toContain('你已有同名团队,请更换名称'),
|
|
165
|
+
)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('emits confirmed skip, join, authentication, upgrade and invitation actions', async () => {
|
|
169
|
+
const onboarding = document.createElement(INTERNAL_ORGANIZATION_ONBOARDING_TAG)
|
|
170
|
+
const skipped = vi.fn()
|
|
171
|
+
const join = vi.fn()
|
|
172
|
+
onboarding.addEventListener('onboarding-skipped', skipped)
|
|
173
|
+
onboarding.addEventListener('join-organization-requested', join)
|
|
174
|
+
onboarding.configure({ transport: createTransport() })
|
|
175
|
+
document.body.append(onboarding)
|
|
176
|
+
await settle(onboarding)
|
|
177
|
+
|
|
178
|
+
buttonByText(onboarding, '跳过').click()
|
|
179
|
+
await settle(onboarding)
|
|
180
|
+
expect(onboarding.shadowRoot?.textContent).toContain('确定暂不创建团队 / 企业')
|
|
181
|
+
buttonByText(onboarding, '跳过,先逛逛').click()
|
|
182
|
+
expect(skipped.mock.calls[0]?.[0].detail).toEqual({ source: 'method-selection' })
|
|
183
|
+
|
|
184
|
+
onboarding.start('unassigned')
|
|
185
|
+
await settle(onboarding)
|
|
186
|
+
expect(onboarding.shadowRoot?.textContent).toContain('你还没有归属的团队 / 企业')
|
|
187
|
+
buttonByText(onboarding, '用邀请码加入').click()
|
|
188
|
+
expect(join.mock.calls[0]?.[0].detail).toEqual({})
|
|
189
|
+
expect(join).toHaveBeenCalledTimes(1)
|
|
190
|
+
|
|
191
|
+
const teamOnboarding = document.createElement(INTERNAL_ORGANIZATION_ONBOARDING_TAG)
|
|
192
|
+
const upgrade = vi.fn()
|
|
193
|
+
const invite = vi.fn()
|
|
194
|
+
teamOnboarding.addEventListener('team-upgrade-requested', upgrade)
|
|
195
|
+
teamOnboarding.addEventListener('team-invite-members-requested', invite)
|
|
196
|
+
teamOnboarding.configure({ transport: createTransport() })
|
|
197
|
+
document.body.append(teamOnboarding)
|
|
198
|
+
await settle(teamOnboarding)
|
|
199
|
+
buttonByText(teamOnboarding, '创建团队').click()
|
|
200
|
+
buttonByText(teamOnboarding, '下一步').click()
|
|
201
|
+
await settle(teamOnboarding)
|
|
202
|
+
updateInput(teamOnboarding, '#team-name', '星河增长项目组')
|
|
203
|
+
submit(teamOnboarding)
|
|
204
|
+
await vi.waitFor(() => expect(teamOnboarding.shadowRoot?.textContent).toContain('无需认证'))
|
|
205
|
+
buttonByText(teamOnboarding, '升级为企业').click()
|
|
206
|
+
buttonByText(teamOnboarding, '邀请成员').click()
|
|
207
|
+
expect(upgrade.mock.calls[0]?.[0].detail).toEqual({ organizationId: 'team-1' })
|
|
208
|
+
expect(invite.mock.calls[0]?.[0].detail).toEqual({ organizationId: 'team-1' })
|
|
209
|
+
|
|
210
|
+
const enterpriseOnboarding = document.createElement(INTERNAL_ORGANIZATION_ONBOARDING_TAG)
|
|
211
|
+
const authenticate = vi.fn()
|
|
212
|
+
const deferred = vi.fn()
|
|
213
|
+
enterpriseOnboarding.addEventListener('enterprise-authentication-requested', authenticate)
|
|
214
|
+
enterpriseOnboarding.addEventListener('enterprise-authentication-deferred', deferred)
|
|
215
|
+
enterpriseOnboarding.configure({ transport: createTransport() })
|
|
216
|
+
document.body.append(enterpriseOnboarding)
|
|
217
|
+
await settle(enterpriseOnboarding)
|
|
218
|
+
buttonByText(enterpriseOnboarding, '下一步').click()
|
|
219
|
+
await settle(enterpriseOnboarding)
|
|
220
|
+
updateInput(enterpriseOnboarding, '#organization-name', '星河科技')
|
|
221
|
+
updateInput(enterpriseOnboarding, '#enterprise-name', '北京星河科技有限公司')
|
|
222
|
+
updateInput(enterpriseOnboarding, '#credit-code', '91110108MA01ABC123')
|
|
223
|
+
submit(enterpriseOnboarding)
|
|
224
|
+
await vi.waitFor(() => expect(enterpriseOnboarding.shadowRoot?.textContent).toContain('未认证'))
|
|
225
|
+
buttonByText(enterpriseOnboarding, '稍后认证').click()
|
|
226
|
+
buttonByText(enterpriseOnboarding, '去认证').click()
|
|
227
|
+
expect(deferred.mock.calls[0]?.[0].detail).toEqual({ organizationId: 'enterprise-1' })
|
|
228
|
+
expect(authenticate.mock.calls[0]?.[0].detail).toEqual({ organizationId: 'enterprise-1' })
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('renders transport text as text and exposes only stable errors', async () => {
|
|
232
|
+
const onboarding = document.createElement(INTERNAL_ORGANIZATION_ONBOARDING_TAG)
|
|
233
|
+
const errors = vi.fn()
|
|
234
|
+
onboarding.addEventListener('error', errors)
|
|
235
|
+
onboarding.configure({
|
|
236
|
+
transport: createTransport({
|
|
237
|
+
createTeam: vi.fn(async () => {
|
|
238
|
+
throw new Error('raw backend response with jwt-secret')
|
|
239
|
+
}),
|
|
240
|
+
}),
|
|
241
|
+
})
|
|
242
|
+
document.body.append(onboarding)
|
|
243
|
+
await settle(onboarding)
|
|
244
|
+
buttonByText(onboarding, '创建团队').click()
|
|
245
|
+
buttonByText(onboarding, '下一步').click()
|
|
246
|
+
await settle(onboarding)
|
|
247
|
+
updateInput(onboarding, '#team-name', '<img src=x onerror=unsafe()>')
|
|
248
|
+
submit(onboarding)
|
|
249
|
+
await vi.waitFor(() => expect(errors).toHaveBeenCalledTimes(1))
|
|
250
|
+
await settle(onboarding)
|
|
251
|
+
|
|
252
|
+
expect(onboarding.shadowRoot?.querySelector('img')).toBeNull()
|
|
253
|
+
expect(onboarding.shadowRoot?.innerHTML).not.toContain('jwt-secret')
|
|
254
|
+
expect(errors.mock.calls[0]?.[0].detail).toEqual({
|
|
255
|
+
code: 'TEAM_CREATE_FAILED',
|
|
256
|
+
message: '团队创建失败,请稍后重试',
|
|
257
|
+
recoverable: true,
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import {
|
|
5
|
+
ORGANIZATION_SELECTOR_TAG,
|
|
6
|
+
UniplatOrganizationSelector,
|
|
7
|
+
type OrganizationSelectorConfig,
|
|
8
|
+
} from '../src'
|
|
9
|
+
|
|
10
|
+
const CONFIG: OrganizationSelectorConfig = {
|
|
11
|
+
selectedOrganizationId: 'enterprise-beijing',
|
|
12
|
+
rememberSelection: true,
|
|
13
|
+
organizations: [
|
|
14
|
+
{
|
|
15
|
+
id: 'enterprise-beijing',
|
|
16
|
+
name: '北京星河科技有限公司',
|
|
17
|
+
type: 'enterprise',
|
|
18
|
+
verificationStatus: 'verified',
|
|
19
|
+
role: '超管',
|
|
20
|
+
memberCount: 128,
|
|
21
|
+
lastEnteredLabel: '上次进入 2 小时前',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: 'enterprise-shanghai',
|
|
25
|
+
name: '星河(上海)分公司',
|
|
26
|
+
type: 'enterprise',
|
|
27
|
+
verificationStatus: 'unverified',
|
|
28
|
+
role: '管理员',
|
|
29
|
+
memberCount: 46,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'team-growth',
|
|
33
|
+
name: '星河增长项目组',
|
|
34
|
+
type: 'team',
|
|
35
|
+
role: '团队负责人',
|
|
36
|
+
memberCount: 9,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'enterprise-expired',
|
|
40
|
+
name: '已过期企业',
|
|
41
|
+
type: 'enterprise',
|
|
42
|
+
verificationStatus: 'expired',
|
|
43
|
+
role: '管理员',
|
|
44
|
+
memberCount: 2,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'team-campus',
|
|
48
|
+
name: '2026 校招筹备团队',
|
|
49
|
+
type: 'team',
|
|
50
|
+
role: '成员',
|
|
51
|
+
memberCount: 5,
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function settle(selector: UniplatOrganizationSelector): Promise<void> {
|
|
57
|
+
await selector.updateComplete
|
|
58
|
+
await Promise.resolve()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('<uniplat-organization-selector>', () => {
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
document.body.replaceChildren()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('registers and renders all enterprise and team memberships from the prototype', async () => {
|
|
67
|
+
expect(customElements.get(ORGANIZATION_SELECTOR_TAG)).toBe(UniplatOrganizationSelector)
|
|
68
|
+
const selector = document.createElement(ORGANIZATION_SELECTOR_TAG)
|
|
69
|
+
selector.configure(CONFIG)
|
|
70
|
+
document.body.append(selector)
|
|
71
|
+
await settle(selector)
|
|
72
|
+
|
|
73
|
+
expect(selector.shadowRoot?.textContent).toContain('你归属 5 个团队 / 企业')
|
|
74
|
+
expect(selector.shadowRoot?.querySelectorAll('.organization')).toHaveLength(5)
|
|
75
|
+
expect(selector.shadowRoot?.textContent).toContain('北京星河科技有限公司')
|
|
76
|
+
expect(selector.shadowRoot?.textContent).toContain('企业')
|
|
77
|
+
expect(selector.shadowRoot?.textContent).toContain('未认证')
|
|
78
|
+
expect(selector.shadowRoot?.textContent).toContain('已过期')
|
|
79
|
+
expect(selector.shadowRoot?.textContent).toContain('团队')
|
|
80
|
+
expect(selector.shadowRoot?.textContent).toContain('超管 · 128 人 · 上次进入 2 小时前')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('keeps the choice local until enter and emits only ids and the remember preference', async () => {
|
|
84
|
+
const selector = document.createElement(ORGANIZATION_SELECTOR_TAG)
|
|
85
|
+
const enter = vi.fn()
|
|
86
|
+
const create = vi.fn()
|
|
87
|
+
selector.addEventListener('organization-enter-requested', enter)
|
|
88
|
+
selector.addEventListener('organization-create-requested', create)
|
|
89
|
+
selector.configure(CONFIG)
|
|
90
|
+
document.body.append(selector)
|
|
91
|
+
await settle(selector)
|
|
92
|
+
|
|
93
|
+
selector.shadowRoot?.querySelectorAll<HTMLButtonElement>('.organization')[2]?.click()
|
|
94
|
+
const remember = selector.shadowRoot?.querySelector<HTMLInputElement>('.remember input')
|
|
95
|
+
if (!remember) throw new Error('Missing remember checkbox')
|
|
96
|
+
remember.checked = false
|
|
97
|
+
remember.dispatchEvent(new Event('change', { bubbles: true }))
|
|
98
|
+
selector.shadowRoot?.querySelector<HTMLButtonElement>('.enter')?.click()
|
|
99
|
+
selector.shadowRoot?.querySelector<HTMLButtonElement>('.create')?.click()
|
|
100
|
+
|
|
101
|
+
expect(enter.mock.calls[0]?.[0].detail).toEqual({
|
|
102
|
+
organizationId: 'team-growth',
|
|
103
|
+
rememberSelection: false,
|
|
104
|
+
})
|
|
105
|
+
expect(JSON.stringify(enter.mock.calls[0]?.[0].detail)).not.toContain('星河')
|
|
106
|
+
expect(create.mock.calls[0]?.[0].detail).toEqual({})
|
|
107
|
+
expect(enter.mock.calls[0]?.[0].composed).toBe(true)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('rejects duplicate ids and renders external labels as text', async () => {
|
|
111
|
+
const selector = document.createElement(ORGANIZATION_SELECTOR_TAG)
|
|
112
|
+
expect(() =>
|
|
113
|
+
selector.configure({
|
|
114
|
+
organizations: [CONFIG.organizations[0]!, CONFIG.organizations[0]!],
|
|
115
|
+
}),
|
|
116
|
+
).toThrow(TypeError)
|
|
117
|
+
|
|
118
|
+
selector.configure({
|
|
119
|
+
organizations: [{ id: 'safe-id', name: '<img src=x onerror=unsafe()>', type: 'team' }],
|
|
120
|
+
})
|
|
121
|
+
document.body.append(selector)
|
|
122
|
+
await settle(selector)
|
|
123
|
+
|
|
124
|
+
expect(selector.shadowRoot?.querySelector('img')).toBeNull()
|
|
125
|
+
expect(selector.shadowRoot?.textContent).toContain('<img src=x onerror=unsafe()>')
|
|
126
|
+
})
|
|
127
|
+
})
|