Skip to content

Commit 98e769e

Browse files
committed
moved peoplesearch to web component
1 parent 2d6c429 commit 98e769e

16 files changed

Lines changed: 1032 additions & 1336 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,27 @@ import { SignupButton } from 'solid-ui/components/signup-button'
318318
<solid-ui-signup-button label="Get a Pod" signup-url="https://solidproject.org/get_a_pod" icon="https://example.com/icon.svg"></solid-ui-signup-button>
319319
```
320320

321+
### solid-ui-people-search
322+
323+
A standalone people-search component built on top of `<solid-ui-combobox>`. It discovers people from the authenticated user's FOAF graph, linked address books, and the Solid catalog, then emits `person-select` with the selected person's details.
324+
325+
**Subpath exports:** `solid-ui/components/forms/people-search`, `solid-ui/components/people-search`
326+
327+
```typescript
328+
import { PeopleSearch } from 'solid-ui/components/forms/people-search'
329+
```
330+
331+
```html
332+
<solid-ui-people-search></solid-ui-people-search>
333+
```
334+
335+
```typescript
336+
const peopleSearch = document.querySelector('solid-ui-people-search') as PeopleSearch
337+
peopleSearch.addEventListener('person-select', (event: CustomEvent) => {
338+
console.log(event.detail.person)
339+
})
340+
```
341+
321342
### Component build pipeline
322343

323344
Web components use a two-stage build to produce a clean public runtime layout while keeping internal TypeScript artifacts separate:

package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,16 @@
9494
"types": "./dist/components/combobox/index.d.ts",
9595
"import": "./dist/components/combobox/index.esm.js",
9696
"require": "./dist/components/combobox/index.js"
97+
},
98+
"./components/forms/people-search": {
99+
"types": "./dist/components/peopleSearch/index.d.ts",
100+
"import": "./dist/components/peopleSearch/index.esm.js",
101+
"require": "./dist/components/peopleSearch/index.js"
102+
},
103+
"./components/people-search": {
104+
"types": "./dist/components/peopleSearch/index.d.ts",
105+
"import": "./dist/components/peopleSearch/index.esm.js",
106+
"require": "./dist/components/peopleSearch/index.js"
97107
}
98108
},
99109
"files": [

scripts/component-manifest.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ export const v2Components = [
3838
sourceDir: 'combobox',
3939
sourcePath: 'forms/combobox',
4040
exportNames: ['forms/combobox', 'combobox']
41+
},
42+
{
43+
sourceDir: 'peopleSearch',
44+
sourcePath: 'forms/peopleSearch',
45+
exportNames: ['forms/people-search', 'people-search']
4146
}
4247
]
4348

src/stories/PeopleSearch.stories.js

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import * as UI from '../../src/index'
1+
import '../v2/components/forms/peopleSearch/index'
22
import ContactsModuleRdfLib from '@solid-data-modules/contacts-rdflib'
33

44
const CATALOG_URL =
@@ -154,12 +154,14 @@ function installPeopleSearchMocks () {
154154
}
155155

156156
export default {
157-
title: 'Widgets/PeopleSearch'
157+
title: 'Forms/PeopleSearch'
158158
}
159159

160160
export const SignedOut = {
161161
render: () => {
162-
return UI.widgets.createPeopleSearch(document, makeMockKb(), null)
162+
const element = document.createElement('solid-ui-people-search')
163+
element.store = makeMockKb()
164+
return element
163165
},
164166
name: 'signed out'
165167
}
@@ -174,12 +176,21 @@ export const WithMockData = {
174176
'Mocked sources: address book, foaf:knows, and Solid catalog. Type to filter.'
175177
wrapper.appendChild(info)
176178

179+
const originalCurrentUser = window.SolidLogic?.authn?.currentUser
177180
const me = $rdf.namedNode('https://demo.example/profile/card#me')
178-
const picker = UI.widgets.createPeopleSearch(document, makeMockKb(), me)
181+
if (window.SolidLogic?.authn) {
182+
window.SolidLogic.authn.currentUser = () => me
183+
}
184+
const picker = document.createElement('solid-ui-people-search')
185+
picker.store = makeMockKb()
186+
picker.openProfilesOnSelect = false
179187
wrapper.appendChild(picker)
180188

181189
const cleanup = function () {
182190
restoreMocks()
191+
if (window.SolidLogic?.authn) {
192+
window.SolidLogic.authn.currentUser = originalCurrentUser
193+
}
183194
wrapper.removeEventListener('DOMNodeRemovedFromDocument', cleanup)
184195
}
185196
wrapper.addEventListener('DOMNodeRemovedFromDocument', cleanup)

src/v2/components/forms/combobox/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ The component works with suggestion objects shaped like:
9595
type ComboboxSuggestion = {
9696
label: string
9797
value: string
98+
description?: string
9899
disabled?: boolean
99100
publicId?: string
100101
meta?: Record<string, unknown>
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
2+
import { namedNode } from 'rdflib'
3+
import { authSession, authn } from 'solid-logic'
4+
import { PeopleSearch } from './PeopleSearch'
5+
import './index'
6+
import ns from '../../../../ns'
7+
8+
jest.mock('@solid-data-modules/contacts-rdflib', () => ({
9+
__esModule: true,
10+
default: jest.fn().mockImplementation(() => ({
11+
listAddressBooks: jest.fn(async () => ({ publicUris: [], privateUris: [] })),
12+
readAddressBook: jest.fn(async () => ({ contacts: [] }))
13+
}))
14+
}))
15+
16+
jest.mock('solid-logic', () => ({
17+
authSession: {
18+
events: {
19+
on: jest.fn(),
20+
off: jest.fn()
21+
}
22+
},
23+
authn: {
24+
currentUser: jest.fn()
25+
},
26+
solidLogicSingleton: {
27+
store: null
28+
}
29+
}))
30+
31+
const mockCurrentUser = authn.currentUser as jest.Mock
32+
const mockOn = authSession.events.on as jest.Mock
33+
const mockOff = authSession.events.off as jest.Mock
34+
35+
function getPortalRoot () {
36+
const portalHost = document.querySelector('[data-solid-ui-combobox-portal]') as HTMLDivElement | null
37+
return portalHost?.shadowRoot ?? null
38+
}
39+
40+
async function flushUpdates () {
41+
await Promise.resolve()
42+
await Promise.resolve()
43+
await Promise.resolve()
44+
}
45+
46+
describe('SolidUIPeopleSearch', () => {
47+
beforeEach(() => {
48+
document.body.innerHTML = ''
49+
mockCurrentUser.mockReset()
50+
mockOn.mockReset()
51+
mockOff.mockReset()
52+
;(globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch = undefined
53+
})
54+
55+
it('is defined as a custom element', () => {
56+
expect(customElements.get('solid-ui-people-search')).toBe(PeopleSearch)
57+
})
58+
59+
it('shows a sign-in message when no user is authenticated', async () => {
60+
mockCurrentUser.mockReturnValue(null)
61+
62+
const peopleSearch = new PeopleSearch()
63+
document.body.appendChild(peopleSearch)
64+
await peopleSearch.updateComplete
65+
66+
const status = peopleSearch.shadowRoot?.querySelector('.status') as HTMLElement
67+
const combobox = peopleSearch.shadowRoot?.querySelector('solid-ui-combobox') as HTMLElement
68+
69+
expect(combobox).not.toBeNull()
70+
expect(status.textContent).toContain('Sign in to search contacts.')
71+
expect(mockOn).toHaveBeenCalledWith('login', expect.any(Function))
72+
expect(mockOn).toHaveBeenCalledWith('logout', expect.any(Function))
73+
})
74+
75+
it('loads FOAF suggestions and emits person-select with relationship details', async () => {
76+
const me = namedNode('https://example.com/profile/card#me')
77+
const friend = namedNode('https://alice.example/profile/card#me')
78+
79+
mockCurrentUser.mockReturnValue(me)
80+
81+
const store = {
82+
fetcher: {
83+
load: jest.fn(async () => undefined)
84+
},
85+
updater: {},
86+
each: jest.fn((subject: NamedNode, predicate: NamedNode) => {
87+
if (subject.value === me.value && predicate.value === ns.foaf('knows').value) {
88+
return [friend]
89+
}
90+
return []
91+
}),
92+
any: jest.fn((subject: NamedNode, predicate: NamedNode) => {
93+
if (subject.value === friend.value && predicate.value === ns.foaf('name').value) {
94+
return { value: 'Alice Example' }
95+
}
96+
return null
97+
}),
98+
anyValue: jest.fn(() => null)
99+
} as any
100+
101+
const openSpy = jest.spyOn(window, 'open').mockReturnValue(null)
102+
const selected = jest.fn()
103+
104+
const peopleSearch = new PeopleSearch()
105+
peopleSearch.store = store
106+
peopleSearch.openProfilesOnSelect = false
107+
peopleSearch.addEventListener('person-select', (event: Event) => {
108+
selected((event as CustomEvent).detail)
109+
})
110+
111+
document.body.appendChild(peopleSearch)
112+
await peopleSearch.updateComplete
113+
await flushUpdates()
114+
await peopleSearch.updateComplete
115+
116+
const combobox = peopleSearch.shadowRoot?.querySelector('solid-ui-combobox') as any
117+
const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement
118+
119+
input.value = 'Alice'
120+
input.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
121+
await flushUpdates()
122+
await combobox.updateComplete
123+
124+
const portalRoot = getPortalRoot()
125+
const options = portalRoot?.querySelectorAll('[role="option"]') as NodeListOf<HTMLElement>
126+
127+
expect(options).toHaveLength(1)
128+
expect(options[0].textContent).toContain('Alice Example')
129+
expect(options[0].textContent).toContain('Friend')
130+
131+
options[0].click()
132+
await flushUpdates()
133+
134+
expect(selected).toHaveBeenCalledWith({
135+
person: {
136+
name: 'Alice Example',
137+
webId: 'https://alice.example/profile/card#me',
138+
relationshipLabel: 'Friend'
139+
}
140+
})
141+
expect(openSpy).not.toHaveBeenCalled()
142+
143+
openSpy.mockRestore()
144+
})
145+
})

0 commit comments

Comments
 (0)