Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/common/src/components/AutocompleteInput.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useState, Fragment } from 'react'
import { Combobox } from '@headlessui/react'

function XIcon ({ className, onClick }) {
return (
<svg className={className} onClick={onClick} xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor' strokeWidth={2}>
<path strokeLinecap='round' strokeLinejoin='round' d='M6 18L18 6M6 6l12 12' />
</svg>
)
}
export default function AutocompleteInput ({ items, selectedItems, setSelectedItems }) {
const [query, setQuery] = useState('')

function removeSelectedItem (itemId) {
const newSelectedItems = selectedItems.filter(item => item.id !== itemId)
setSelectedItems(newSelectedItems)
}

const filteredItems =
query === ''
? items
: items.filter(({ name }) => name.toLowerCase().includes(query.toLowerCase()))

return (
<Combobox value={selectedItems} onChange={setSelectedItems} multiple>
<Combobox.Input
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to allow for custom values so we can still add members by id only [0]

[0] https://headlessui.dev/react/combobox#allowing-custom-values

onChange={(event) => setQuery(event.target.value)}
displayValue={(item) => item.name}
className='block w-full rounded-md border-gray-300'
/>
{selectedItems.length > 0 && (
<div className='flex mt-1'>
{selectedItems.map(({ id, name }) => (
<div key={id} className='flex p-1 border-solid border-2 border-gray-300 rounded-md mr-2'>
{name} <XIcon className='w-4 ml-3' onClick={() => removeSelectedItem(id)} />
</div>
))}
</div>
)}
<Combobox.Options className='border-solid border-2 border-gray-300 rounded-md'>
{filteredItems.map((item) => (
/* Use the `active` state to conditionally style the active option. */
<Combobox.Option key={item.id} value={item} as={Fragment}>
{({ active }) => (
<li
className={`px-4 py-2 ${
active ? 'bg-kernel-green-dark text-white' : 'bg-white text-black'
}`}
>
{item.name}
</li>
)}
</Combobox.Option>
))}
</Combobox.Options>
</Combobox>
)
}
6 changes: 4 additions & 2 deletions packages/common/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import { ServicesProvider, useServices } from './contexts/ServicesContext.js'
// utils
import { getUrl } from './utils/urls'
import timeUtils from './utils/time'
import errorUtils from './utils/errors'

// components
import AutocompleteInput from './components/AutocompleteInput'
import Login from './components/Login'
import Navbar from './components/Navbar'
import NavbarLink from './components/NavbarLink'
Expand All @@ -32,7 +34,7 @@ import linesVector from './assets/images/lines.png'
export {
jwtService, rpcClient,
ServicesProvider, useServices,
getUrl, timeUtils,
Login, Footer, FooterLink, Navbar, NavbarLink, Alert, Loading,
getUrl, timeUtils, errorUtils,
AutocompleteInput, Login, Footer, FooterLink, Navbar, NavbarLink, Alert, Loading,
linesVector
}
20 changes: 20 additions & 0 deletions packages/common/src/utils/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@

/**
* Copyright (c) Kernel
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

const readable = (error) => {
if (error.toLowerCase().indexOf('consent') > 0) {
return 'You need to share your profile data in order to view recommendations.'
}
if (error.toLowerCase().indexOf('profile') > 0) {
return 'You need to create your profile first in order to view recommendations.'
}
return 'You need to refresh your auth token by reloading this page.'
}

export default { readable }
12 changes: 2 additions & 10 deletions packages/explorer/src/views/Browse.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { useEffect, useReducer } from 'react'
import { useNavigate } from 'react-router-dom'
import { useServices, timeUtils, Navbar, Footer } from '@kernel/common'
import { useServices, timeUtils, errorUtils, Navbar, Footer } from '@kernel/common'

import AppConfig from 'App.config'

Expand All @@ -33,15 +33,7 @@ const reducer = (state, action) => {

const { humanize } = timeUtils

const readable = (error) => {
if (error.toLowerCase().indexOf('consent') > 0) {
return 'You need to share your profile data in order to view recommendations.'
}
if (error.toLowerCase().indexOf('profile') > 0) {
return 'You need to create your profile first in order to view recommendations.'
}
return 'You need to refresh your auth token by reloading this page.'
}
const { readable } = errorUtils

const Page = () => {
const [state, dispatch] = useReducer(reducer, INITIAL_STATE)
Expand Down
2 changes: 2 additions & 0 deletions packages/groups/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ REACT_APP_STORAGE_ENDPOINT_DEV="http://localhost:3001/storage/rpc"
REACT_APP_STORAGE_ENDPOINT_STAGING="https://staging.services.kernel.community/storage/rpc"
REACT_APP_TASK_ENDPOINT_DEV="http://localhost:3001/task/rpc"
REACT_APP_TASK_ENDPOINT_STAGING="https://staging.services.kernel.community/task/rpc"
REACT_APP_QUERY_ENDPOINT_DEV="http://localhost:3001/query/rpc"
REACT_APP_QUERY_ENDPOINT_STAGING="https://staging.services.kernel.community/query/rpc"
1 change: 1 addition & 0 deletions packages/groups/.env.production
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ REACT_APP_AUTH_ENDPOINT_STAGING="https://staging.services.kernel.community/auth/
REACT_APP_AUTH_URL_STAGING="https://staging.wallet.kernel.community/auth"
REACT_APP_STORAGE_ENDPOINT_STAGING="https://staging.services.kernel.community/storage/rpc"
REACT_APP_TASK_ENDPOINT_STAGING="https://staging.services.kernel.community/task/rpc"
REACT_APP_QUERY_ENDPOINT_STAGING="https://staging.services.kernel.community/query/rpc"
49 changes: 30 additions & 19 deletions packages/groups/src/components/Form.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@

import { useEffect, useReducer } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useServices } from '@kernel/common'
import { useServices, AutocompleteInput, errorUtils } from '@kernel/common'

import AppConfig from 'App.config'

const MODES = { create: 'create', update: 'update' }
const KEYS = ['name', 'memberIdsText']
const STATE_KEYS = ['group', 'groups', 'member', 'members', 'error', 'status', 'taskService']
const KEYS = ['name', 'memberIdsText', 'groupMembers']
const STATE_KEYS = ['group', 'groups', 'member', 'members', 'profiles', 'error', 'status', 'taskService']
const INITIAL_STATE = STATE_KEYS.concat(KEYS)
.reduce((acc, k) => Object.assign(acc, { [k]: '' }), {})

Expand All @@ -23,6 +23,8 @@ Object.keys(INITIAL_STATE)
.forEach((k) => {
actions[k] = (state, e) => Object.assign({}, state, { [k]: e })
})
INITIAL_STATE.groupMembers = []
INITIAL_STATE.profiles = []

const reducer = (state, action) => {
try {
Expand Down Expand Up @@ -50,8 +52,8 @@ const value = (state, type) => {
}

// dedupe, sort
const textToArray = (s) => [...new Set(s.split(',').map((e) => e.trim()))].sort()
const arrayToText = (arr) => arr.join(', ')
const getMemberIds = (groupMembers) => groupMembers.map(groupMember => groupMember.id)
const transformProfiles = (profiles) => Object.values(profiles).map(({ data: { memberId, name } }) => ({ id: memberId, name }))
const resetAlerts = (dispatch) => {
dispatch({ type: 'error', payload: '' })
dispatch({ type: 'status', payload: 'submitting' })
Expand All @@ -60,9 +62,9 @@ const resetAlerts = (dispatch) => {
const create = async (state, dispatch, e) => {
e.preventDefault()
resetAlerts(dispatch)
const { groups, memberIdsText, name, taskService } = state
const memberIds = textToArray(memberIdsText)
if (!name.length || !memberIdsText.length) {
const { groups, groupMembers, name, taskService } = state
const memberIds = getMemberIds(groupMembers)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs to support the case when a member is added by id instead of name.

if (!name.length || !groupMembers.length) {
dispatch({ type: 'error', payload: 'name and member ids are required' })
return
}
Expand All @@ -80,9 +82,9 @@ const create = async (state, dispatch, e) => {
const update = async (state, dispatch, e) => {
e.preventDefault()
resetAlerts(dispatch)
const { group, groups, memberIdsText, name, taskService } = state
const { group, groups, groupMembers, name, taskService } = state
const groupId = group.id
const memberIds = textToArray(memberIdsText)
const memberIds = getMemberIds(groupMembers)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

try {
if (group.data.name !== name) {
await groups.patch(groupId, { name })
Expand All @@ -107,6 +109,7 @@ const Form = () => {

const { services, currentUser } = useServices()
const user = currentUser()
const { readable } = errorUtils

useEffect(() => {
if (!user || user.role > AppConfig.minRole) {
Expand All @@ -117,14 +120,22 @@ const Form = () => {
useEffect(() => {
(async () => {
dispatch({ type: 'status', payload: 'Loading' })
const { entityFactory, taskService } = await services()
const { entityFactory, taskService, queryService } = await services()
dispatch({ type: 'taskService', payload: taskService })
const members = await entityFactory({ resource: 'member' })
const member = await members.get(user.iss)
const groups = await entityFactory({ resource: 'group' })
let transformedProfiles = []
try {
const { profiles } = await queryService.recommend()
transformedProfiles = transformProfiles(profiles)
} catch (error) {
dispatch({ type: 'error', payload: readable(error.message) })
}
dispatch({ type: 'members', payload: members })
dispatch({ type: 'member', payload: member })
dispatch({ type: 'groups', payload: groups })
dispatch({ type: 'profiles', payload: transformedProfiles })
if (mode === MODES.update) {
const entity = await groups.get(group)
dispatch({ type: 'group', payload: entity })
Expand All @@ -133,17 +144,16 @@ const Form = () => {
.forEach(([k, v]) => {
let type = k
let payload = v
// TODO: more ergonomic way to select group memebers
if (k === 'memberIds') {
type = 'memberIdsText'
payload = arrayToText(v)
type = 'groupMembers'
payload = transformedProfiles.filter(item => v.includes(item.id))
}
dispatch({ type, payload })
})
}
dispatch({ type: 'status', payload: '' })
})()
}, [services, user.iss, mode, group])
}, [services, user.iss, mode, group, readable])

return (
<form className='grid grid-cols-1 gap-6'>
Expand All @@ -155,10 +165,11 @@ const Form = () => {
/>
</label>
<label className='block'>
<span className='text-gray-700'>Member Ids (comma separated)</span>
<input
type='text' multiple className={formClass}
value={value(state, 'memberIdsText')} onChange={change.bind(null, dispatch, 'memberIdsText')}
<span className='text-gray-700'>Member Names</span>
<AutocompleteInput
items={value(state, 'profiles')}
selectedItems={value(state, 'groupMembers')}
setSelectedItems={items => dispatch({ type: 'groupMembers', payload: items })}
/>
</label>
<label className='block'>
Expand Down