sanity-plugin-utils 0.0.1
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/LICENSE +21 -0
- package/README.md +154 -0
- package/lib/cjs/index.js +331 -0
- package/lib/cjs/index.js.map +1 -0
- package/lib/esm/index.js +318 -0
- package/lib/esm/index.js.map +1 -0
- package/lib/types/index.d.ts +72 -0
- package/lib/types/index.d.ts.map +1 -0
- package/package.json +75 -0
- package/sanity.json +8 -0
- package/src/components/Feedback.tsx +45 -0
- package/src/components/Table.tsx +79 -0
- package/src/components/UserSelectMenu/index.tsx +133 -0
- package/src/hooks/useListeningQuery.tsx +73 -0
- package/src/hooks/useProjectUsers.tsx +73 -0
- package/src/index.ts +6 -0
- package/v2-incompatible.js +11 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import React, {useEffect, useRef} from 'react'
|
|
2
|
+
import {Box, Text, Menu, MenuItem, TextInput, Flex, Badge} from '@sanity/ui'
|
|
3
|
+
import {AddCircleIcon, RemoveCircleIcon, RestoreIcon} from '@sanity/icons'
|
|
4
|
+
import {UserAvatar} from 'sanity/_unstable'
|
|
5
|
+
|
|
6
|
+
import {UserExtended} from '../../hooks/useProjectUsers'
|
|
7
|
+
|
|
8
|
+
function searchUsers(users: UserExtended[], searchString: string): UserExtended[] {
|
|
9
|
+
return users.filter((user) => {
|
|
10
|
+
const displayName = (user.displayName || '').toLowerCase()
|
|
11
|
+
if (displayName.startsWith(searchString)) return true
|
|
12
|
+
const givenName = (user.givenName || '').toLowerCase()
|
|
13
|
+
if (givenName.startsWith(searchString)) return true
|
|
14
|
+
const middleName = (user.middleName || '').toLowerCase()
|
|
15
|
+
if (middleName.startsWith(searchString)) return true
|
|
16
|
+
const familyName = (user.familyName || '').toLowerCase()
|
|
17
|
+
if (familyName.startsWith(searchString)) return true
|
|
18
|
+
|
|
19
|
+
return false
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type UserSelectMenuProps = {
|
|
24
|
+
value: string[]
|
|
25
|
+
userList: UserExtended[]
|
|
26
|
+
onAdd: any
|
|
27
|
+
onRemove: any
|
|
28
|
+
onClear: any
|
|
29
|
+
open: boolean
|
|
30
|
+
style?: React.CSSProperties
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function UserSelectMenu(props: UserSelectMenuProps) {
|
|
34
|
+
const {value = [], userList = [], onAdd, onRemove, onClear, style = {}} = props
|
|
35
|
+
const [searchString, setSearchString] = React.useState('')
|
|
36
|
+
const searchResults = searchUsers(userList || [], searchString)
|
|
37
|
+
|
|
38
|
+
const me = userList.find((u) => u.isCurrentUser)
|
|
39
|
+
const meAssigned = me && value.includes(me.id)
|
|
40
|
+
|
|
41
|
+
// Focus input on open
|
|
42
|
+
// TODO: Fix focus, it gets immediately taken away
|
|
43
|
+
const input = useRef<HTMLInputElement>()
|
|
44
|
+
// useEffect(() => {
|
|
45
|
+
// if (open && input?.current) {
|
|
46
|
+
// input.current.focus()
|
|
47
|
+
// }
|
|
48
|
+
// }, [open])
|
|
49
|
+
|
|
50
|
+
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
51
|
+
setSearchString(event.target.value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const handleSelect = (isChecked: boolean, user: UserExtended) => {
|
|
55
|
+
if (!isChecked) {
|
|
56
|
+
if (onAdd) onAdd(user.id)
|
|
57
|
+
} else if (onRemove) onRemove(user.id)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const handleAssignMyself = () => {
|
|
61
|
+
if (me && onAdd) onAdd(me.id)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const handleUnassignMyself = () => {
|
|
65
|
+
if (me && onRemove) onRemove(me.id)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const handleClearAssigneesClick = () => {
|
|
69
|
+
if (onClear) onClear()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Menu style={style}>
|
|
74
|
+
{meAssigned ? (
|
|
75
|
+
<MenuItem
|
|
76
|
+
tone="caution"
|
|
77
|
+
disabled={!me}
|
|
78
|
+
onClick={handleUnassignMyself}
|
|
79
|
+
icon={RemoveCircleIcon}
|
|
80
|
+
text="Unassign myself"
|
|
81
|
+
/>
|
|
82
|
+
) : (
|
|
83
|
+
<MenuItem
|
|
84
|
+
tone="positive"
|
|
85
|
+
onClick={handleAssignMyself}
|
|
86
|
+
icon={AddCircleIcon}
|
|
87
|
+
text="Assign myself"
|
|
88
|
+
/>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
<MenuItem
|
|
92
|
+
tone="critical"
|
|
93
|
+
disabled={value.length === 0}
|
|
94
|
+
onClick={handleClearAssigneesClick}
|
|
95
|
+
icon={RestoreIcon}
|
|
96
|
+
text="Clear assignees"
|
|
97
|
+
/>
|
|
98
|
+
|
|
99
|
+
<Box padding={1}>
|
|
100
|
+
<TextInput
|
|
101
|
+
// @ts-ignore TODO: Satisfy ref
|
|
102
|
+
ref={input}
|
|
103
|
+
onChange={handleSearchChange}
|
|
104
|
+
placeholder="Search members"
|
|
105
|
+
value={searchString}
|
|
106
|
+
/>
|
|
107
|
+
</Box>
|
|
108
|
+
|
|
109
|
+
{searchString && searchResults?.length === 0 && <MenuItem disabled text="No matches" />}
|
|
110
|
+
|
|
111
|
+
{searchResults &&
|
|
112
|
+
searchResults.map((user) => (
|
|
113
|
+
<MenuItem
|
|
114
|
+
key={user.id}
|
|
115
|
+
pressed={value.includes(user.id)}
|
|
116
|
+
onClick={() => handleSelect(value.indexOf(user.id) > -1, user)}
|
|
117
|
+
>
|
|
118
|
+
<Flex align="center">
|
|
119
|
+
<UserAvatar user={user} size={1} />
|
|
120
|
+
<Box paddingX={2} flex={1}>
|
|
121
|
+
<Text>{user.displayName}</Text>
|
|
122
|
+
</Box>
|
|
123
|
+
{user.isCurrentUser && (
|
|
124
|
+
<Badge fontSize={1} tone="positive" mode="outline">
|
|
125
|
+
Me
|
|
126
|
+
</Badge>
|
|
127
|
+
)}
|
|
128
|
+
</Flex>
|
|
129
|
+
</MenuItem>
|
|
130
|
+
))}
|
|
131
|
+
</Menu>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {useEffect, useState, useRef} from 'react'
|
|
2
|
+
import {catchError, distinctUntilChanged} from 'rxjs/operators'
|
|
3
|
+
import isEqual from 'react-fast-compare'
|
|
4
|
+
import {useDocumentStore} from 'sanity/_unstable'
|
|
5
|
+
import {Subscription} from 'rxjs'
|
|
6
|
+
|
|
7
|
+
type Params = Record<string, string | number | boolean | string[]>
|
|
8
|
+
|
|
9
|
+
interface ListenQueryOptions {
|
|
10
|
+
tag?: string
|
|
11
|
+
apiVersion?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Value = any
|
|
15
|
+
|
|
16
|
+
interface Config<V> {
|
|
17
|
+
params: Params
|
|
18
|
+
options?: ListenQueryOptions
|
|
19
|
+
initialValue?: null | V
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Return<V> {
|
|
23
|
+
loading: boolean
|
|
24
|
+
error: boolean
|
|
25
|
+
data: null | V
|
|
26
|
+
initialValue?: Value
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_PARAMS = {}
|
|
30
|
+
const DEFAULT_OPTIONS = {apiVersion: `v2022-05-09`}
|
|
31
|
+
const DEFAULT_INITIAL_VALUE = null
|
|
32
|
+
|
|
33
|
+
export function useListeningQuery<V>(
|
|
34
|
+
query: string | {fetch: string; listen: string},
|
|
35
|
+
{
|
|
36
|
+
params = DEFAULT_PARAMS,
|
|
37
|
+
options = DEFAULT_OPTIONS,
|
|
38
|
+
initialValue = DEFAULT_INITIAL_VALUE,
|
|
39
|
+
}: Config<V>
|
|
40
|
+
): Return<V> {
|
|
41
|
+
const [loading, setLoading] = useState(true)
|
|
42
|
+
const [error, setError] = useState(false)
|
|
43
|
+
const [data, setData] = useState(initialValue)
|
|
44
|
+
const subscription = useRef<null | Subscription>(null)
|
|
45
|
+
const documentStore = useDocumentStore()
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (query) {
|
|
49
|
+
subscription.current = documentStore
|
|
50
|
+
.listenQuery(query, params, options)
|
|
51
|
+
.pipe(
|
|
52
|
+
distinctUntilChanged(isEqual),
|
|
53
|
+
catchError((err) => {
|
|
54
|
+
console.error(err)
|
|
55
|
+
setError(err)
|
|
56
|
+
setLoading(false)
|
|
57
|
+
setData(null)
|
|
58
|
+
|
|
59
|
+
return err
|
|
60
|
+
})
|
|
61
|
+
)
|
|
62
|
+
.subscribe((documents) => {
|
|
63
|
+
setData((current: Value) => (isEqual(current, documents) ? current : documents))
|
|
64
|
+
setLoading(false)
|
|
65
|
+
setError(false)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return () => subscription?.current?.unsubscribe()
|
|
70
|
+
}, [query, params, options, documentStore])
|
|
71
|
+
|
|
72
|
+
return {data, loading, error}
|
|
73
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {useState, useEffect} from 'react'
|
|
2
|
+
import {useClient, useWorkspace} from 'sanity'
|
|
3
|
+
|
|
4
|
+
export type UserExtended = {
|
|
5
|
+
createdAt: string
|
|
6
|
+
displayName: string
|
|
7
|
+
email: string
|
|
8
|
+
familyName: string
|
|
9
|
+
givenName: string
|
|
10
|
+
id: string
|
|
11
|
+
imageUrl: string
|
|
12
|
+
isCurrentUser: boolean
|
|
13
|
+
middleName: string
|
|
14
|
+
projectId: string
|
|
15
|
+
provider: string
|
|
16
|
+
sanityUserId: string
|
|
17
|
+
updatedAt: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type UserRole = {
|
|
21
|
+
name: string
|
|
22
|
+
title: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type UserResponse = {
|
|
26
|
+
isRobot: boolean
|
|
27
|
+
projectUserId: string
|
|
28
|
+
roles: UserRole[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Custom hook to fetch user details
|
|
32
|
+
// Built-in hook doesn't fetch all user details
|
|
33
|
+
export function useProjectUsers(): UserExtended[] {
|
|
34
|
+
const {currentUser} = useWorkspace()
|
|
35
|
+
const client = useClient()
|
|
36
|
+
const [users, setUsers] = useState([])
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const {projectId} = client.config()
|
|
40
|
+
|
|
41
|
+
async function getUser(id: string) {
|
|
42
|
+
const userDetails = await client.request({
|
|
43
|
+
url: `/projects/${projectId}/users/${id}`,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
return userDetails
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function getUsersWithRoles() {
|
|
50
|
+
const userRoles = await client
|
|
51
|
+
.request({
|
|
52
|
+
url: `/projects/${projectId}/acl`,
|
|
53
|
+
})
|
|
54
|
+
.then(async (res) =>
|
|
55
|
+
Promise.all(
|
|
56
|
+
res.map(async (user: UserResponse) => ({
|
|
57
|
+
isCurrentUser: user.projectUserId === currentUser?.id,
|
|
58
|
+
...(await getUser(user.projectUserId)),
|
|
59
|
+
}))
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
.catch((err) => err)
|
|
63
|
+
|
|
64
|
+
setUsers(userRoles)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!users.length) {
|
|
68
|
+
getUsersWithRoles()
|
|
69
|
+
}
|
|
70
|
+
}, [client, currentUser?.id, users.length])
|
|
71
|
+
|
|
72
|
+
return users
|
|
73
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export {useListeningQuery} from './hooks/useListeningQuery'
|
|
2
|
+
export {useProjectUsers} from './hooks/useProjectUsers'
|
|
3
|
+
|
|
4
|
+
export {Feedback} from './components/Feedback'
|
|
5
|
+
export {Table, Row, Cell} from './components/Table'
|
|
6
|
+
export {UserSelectMenu} from './components/UserSelectMenu/index'
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const {showIncompatiblePluginDialog} = require('@sanity/incompatible-plugin')
|
|
2
|
+
const {name, version, sanityExchangeUrl} = require('./package.json')
|
|
3
|
+
|
|
4
|
+
export default showIncompatiblePluginDialog({
|
|
5
|
+
name: name,
|
|
6
|
+
versions: {
|
|
7
|
+
v3: version,
|
|
8
|
+
v2: undefined,
|
|
9
|
+
},
|
|
10
|
+
sanityExchangeUrl,
|
|
11
|
+
})
|