diff --git a/frontend/src/Projects/Project/index.tsx b/frontend/src/Projects/Project/index.tsx index 03c0aa0..eee9c22 100644 --- a/frontend/src/Projects/Project/index.tsx +++ b/frontend/src/Projects/Project/index.tsx @@ -3,6 +3,7 @@ import React, { useState, useRef, useEffect, useContext } from 'react'; import updateApolloCache from 'shared/utils/cache'; import GlobalTopNavbar, { ProjectPopup } from 'App/TopNavbar'; import styled from 'styled-components/macro'; +import AsyncSelect from 'react-select/async'; import { usePopup, Popup } from 'shared/components/PopupMenu'; import { useParams, @@ -37,12 +38,18 @@ import Input from 'shared/components/Input'; import Member from 'shared/components/Member'; import EmptyBoard from 'shared/components/EmptyBoard'; import NOOP from 'shared/utils/noop'; +import { Lock } from 'shared/icons'; +import Button from 'shared/components/Button'; +import { useApolloClient } from '@apollo/react-hooks'; +import gql from 'graphql-tag'; import Board, { BoardLoading } from './Board'; import Details from './Details'; import LabelManagerEditor from './LabelManagerEditor'; const CARD_LABEL_VARIANT_STORAGE_KEY = 'card_label_variant'; +const RFC2822_EMAIL = /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/; + const useStateWithLocalStorage = (localStorageKey: string): [string, React.Dispatch>] => { const [value, setValue] = React.useState(localStorage.getItem(localStorageKey) || ''); @@ -76,23 +83,90 @@ type UserManagementPopupProps = { onAddProjectMember: (userID: string) => void; }; +const VisibiltyPrivateIcon = styled(Lock)` + padding-right: 4px; +`; + +const VisibiltyButtonText = styled.span` + color: rgba(${props => props.theme.colors.text.primary}); +`; + +const ShareActions = styled.div` + border-top: 1px solid #414561; + margin-top: 8px; + padding-top: 8px; + display: flex; + align-items: center; + justify-content: space-between; +`; + +const VisibiltyButton = styled.button` + cursor: pointer; + margin: 2px 4px; + padding: 2px 4px; + align-items: center; + justify-content: center; + border-bottom: 1px solid transparent; + &:hover ${VisibiltyButtonText} { + color: rgba(${props => props.theme.colors.text.secondary}); + } + &:hover ${VisibiltyPrivateIcon} { + fill: rgba(${props => props.theme.colors.text.secondary}); + stroke: rgba(${props => props.theme.colors.text.secondary}); + } + &:hover { + border-bottom: 1px solid rgba(${props => props.theme.colors.primary}); + } +`; + +type MemberFilterOptions = { + projectID?: null | string; + teamID?: null | string; + organization?: boolean; +}; + +const fetchMembers = async (client: any, options: MemberFilterOptions, input: string, cb: any) => { + if (input && input.trim().length < 3) { + return []; + } + const res = await client.query({ + query: gql` + query { + searchMembers(input: {SearchFilter:"${input}"}) { + id + similarity + username + fullName + confirmed + joined + } + } + `, + }); + + let results: any = []; + if (res.data && res.data.searchMembers) { + results = [...res.data.searchMembers.map((m: any) => ({ label: m.fullName, value: m.id }))]; + } + + if (RFC2822_EMAIL.test(input)) { + results = [...results, { label: input, value: input }]; + } + + return results; +}; + const UserManagementPopup: React.FC = ({ users, projectMembers, onAddProjectMember }) => { + const client = useApolloClient(); return ( - - - {users - .filter(u => u.id !== projectMembers.find(p => p.id === u.id)?.id) - .map(user => ( - onAddProjectMember(user.id)} - showName - member={user} - taskID="" - /> - ))} - + fetchMembers(client, {}, i, cb)} /> + + + + Private + + ); }; diff --git a/frontend/src/shared/icons/EyeSlash.tsx b/frontend/src/shared/icons/EyeSlash.tsx new file mode 100644 index 0000000..b2de7e1 --- /dev/null +++ b/frontend/src/shared/icons/EyeSlash.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Icon, { IconProps } from './Icon'; + +const EyeSlash: React.FC = ({ width = '16px', height = '16px', className }) => { + return ( + + + + ); +}; + +export default EyeSlash; diff --git a/frontend/src/shared/icons/index.ts b/frontend/src/shared/icons/index.ts index e1b2275..5976a9a 100644 --- a/frontend/src/shared/icons/index.ts +++ b/frontend/src/shared/icons/index.ts @@ -1,6 +1,7 @@ import Cross from './Cross'; import Cog from './Cog'; import Eye from './Eye'; +import EyeSlash from './EyeSlash'; import List from './List'; import At from './At'; import Task from './Task'; @@ -88,5 +89,6 @@ export { Paperclip, Share, Eye, + EyeSlash, List, }; diff --git a/go.mod b/go.mod index 6650609..3df0289 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/google/uuid v1.1.1 github.com/jmoiron/sqlx v1.2.0 github.com/lib/pq v1.3.0 + github.com/lithammer/fuzzysearch v1.1.0 github.com/magefile/mage v1.9.0 github.com/pelletier/go-toml v1.8.0 // indirect github.com/pkg/errors v0.9.1 diff --git a/go.sum b/go.sum index 02f4119..1929136 100644 --- a/go.sum +++ b/go.sum @@ -357,6 +357,8 @@ github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lithammer/fuzzysearch v1.1.0 h1:go9v8tLCrNTTlH42OAaq4eHFe81TDHEnlrMEb6R4f+A= +github.com/lithammer/fuzzysearch v1.1.0/go.mod h1:Bqx4wo8lTOFcJr3ckpY6HA9lEIOO0H5HrkJ5CsN56HQ= github.com/logrusorgru/aurora v0.0.0-20200102142835-e9ef32dff381/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/magefile/mage v1.9.0 h1:t3AU2wNwehMCW97vuqQLtw6puppWXHO+O2MHo5a50XE= github.com/magefile/mage v1.9.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= diff --git a/internal/db/project.sql.go b/internal/db/project.sql.go index e0fefc8..9e8d3e5 100644 --- a/internal/db/project.sql.go +++ b/internal/db/project.sql.go @@ -122,12 +122,12 @@ func (q *Queries) DeleteProjectMember(ctx context.Context, arg DeleteProjectMemb return err } -const getAllProjects = `-- name: GetAllProjects :many -SELECT project_id, team_id, created_at, name FROM project +const getAllProjectsForTeam = `-- name: GetAllProjectsForTeam :many +SELECT project_id, team_id, created_at, name FROM project WHERE team_id = $1 ` -func (q *Queries) GetAllProjects(ctx context.Context) ([]Project, error) { - rows, err := q.db.QueryContext(ctx, getAllProjects) +func (q *Queries) GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error) { + rows, err := q.db.QueryContext(ctx, getAllProjectsForTeam, teamID) if err != nil { return nil, err } @@ -154,12 +154,12 @@ func (q *Queries) GetAllProjects(ctx context.Context) ([]Project, error) { return items, nil } -const getAllProjectsForTeam = `-- name: GetAllProjectsForTeam :many -SELECT project_id, team_id, created_at, name FROM project WHERE team_id = $1 +const getAllTeamProjects = `-- name: GetAllTeamProjects :many +SELECT project_id, team_id, created_at, name FROM project WHERE team_id IS NOT null ` -func (q *Queries) GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error) { - rows, err := q.db.QueryContext(ctx, getAllProjectsForTeam, teamID) +func (q *Queries) GetAllTeamProjects(ctx context.Context) ([]Project, error) { + rows, err := q.db.QueryContext(ctx, getAllTeamProjects) if err != nil { return nil, err } diff --git a/internal/db/querier.go b/internal/db/querier.go index 9cfe1e8..34f3a1d 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -49,10 +49,10 @@ type Querier interface { DeleteUserAccountByID(ctx context.Context, userID uuid.UUID) error GetAllNotificationsForUserID(ctx context.Context, notifierID uuid.UUID) ([]Notification, error) GetAllOrganizations(ctx context.Context) ([]Organization, error) - GetAllProjects(ctx context.Context) ([]Project, error) GetAllProjectsForTeam(ctx context.Context, teamID uuid.UUID) ([]Project, error) GetAllTaskGroups(ctx context.Context) ([]TaskGroup, error) GetAllTasks(ctx context.Context) ([]Task, error) + GetAllTeamProjects(ctx context.Context) ([]Project, error) GetAllTeams(ctx context.Context) ([]Team, error) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error) GetAllVisibleProjectsForUserID(ctx context.Context, userID uuid.UUID) ([]Project, error) @@ -61,6 +61,7 @@ type Querier interface { GetEntityIDForNotificationID(ctx context.Context, notificationID uuid.UUID) (uuid.UUID, error) GetLabelColorByID(ctx context.Context, labelColorID uuid.UUID) (LabelColor, error) GetLabelColors(ctx context.Context) ([]LabelColor, error) + GetMemberData(ctx context.Context) ([]GetMemberDataRow, error) GetMemberProjectIDsForUserID(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error) GetMemberTeamIDsForUserID(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error) GetNotificationForNotificationID(ctx context.Context, notificationID uuid.UUID) (GetNotificationForNotificationIDRow, error) diff --git a/internal/db/query/user_accounts.sql b/internal/db/query/user_accounts.sql index f386721..09d1bff 100644 --- a/internal/db/query/user_accounts.sql +++ b/internal/db/query/user_accounts.sql @@ -15,6 +15,9 @@ INSERT INTO user_account(full_name, initials, email, username, created_at, passw UPDATE user_account SET profile_avatar_url = $2 WHERE user_id = $1 RETURNING *; +-- name: GetMemberData :many +SELECT username, email, user_id FROM user_account; + -- name: UpdateUserAccountInfo :one UPDATE user_account SET bio = $2, full_name = $3, initials = $4, email = $5 WHERE user_id = $1 RETURNING *; diff --git a/internal/db/user_accounts.sql.go b/internal/db/user_accounts.sql.go index fb8460c..0a523dd 100644 --- a/internal/db/user_accounts.sql.go +++ b/internal/db/user_accounts.sql.go @@ -101,6 +101,39 @@ func (q *Queries) GetAllUserAccounts(ctx context.Context) ([]UserAccount, error) return items, nil } +const getMemberData = `-- name: GetMemberData :many +SELECT username, email, user_id FROM user_account +` + +type GetMemberDataRow struct { + Username string `json:"username"` + Email string `json:"email"` + UserID uuid.UUID `json:"user_id"` +} + +func (q *Queries) GetMemberData(ctx context.Context) ([]GetMemberDataRow, error) { + rows, err := q.db.QueryContext(ctx, getMemberData) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetMemberDataRow + for rows.Next() { + var i GetMemberDataRow + if err := rows.Scan(&i.Username, &i.Email, &i.UserID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getRoleForUserID = `-- name: GetRoleForUserID :one SELECT username, role.code, role.name FROM user_account INNER JOIN role ON role.code = user_account.role_code diff --git a/internal/graph/generated.go b/internal/graph/generated.go index d208930..8c640f4 100644 --- a/internal/graph/generated.go +++ b/internal/graph/generated.go @@ -160,6 +160,15 @@ type ComplexityRoot struct { Teams func(childComplexity int) int } + MemberSearchResult struct { + Confirmed func(childComplexity int) int + FullName func(childComplexity int) int + ID func(childComplexity int) int + Joined func(childComplexity int) int + Similarity func(childComplexity int) int + Username func(childComplexity int) int + } + Mutation struct { AddTaskLabel func(childComplexity int, input *AddTaskLabelInput) int AssignTask func(childComplexity int, input *AssignTaskInput) int @@ -289,6 +298,7 @@ type ComplexityRoot struct { Notifications func(childComplexity int) int Organizations func(childComplexity int) int Projects func(childComplexity int, input *ProjectsFilter) int + SearchMembers func(childComplexity int, input MemberSearchFilter) int TaskGroups func(childComplexity int) int Teams func(childComplexity int) int Users func(childComplexity int) int @@ -528,6 +538,7 @@ type QueryResolver interface { TaskGroups(ctx context.Context) ([]db.TaskGroup, error) Me(ctx context.Context) (*MePayload, error) Notifications(ctx context.Context) ([]db.Notification, error) + SearchMembers(ctx context.Context, input MemberSearchFilter) ([]MemberSearchResult, error) } type RefreshTokenResolver interface { ID(ctx context.Context, obj *db.RefreshToken) (uuid.UUID, error) @@ -917,6 +928,48 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.MemberList.Teams(childComplexity), true + case "MemberSearchResult.confirmed": + if e.complexity.MemberSearchResult.Confirmed == nil { + break + } + + return e.complexity.MemberSearchResult.Confirmed(childComplexity), true + + case "MemberSearchResult.fullName": + if e.complexity.MemberSearchResult.FullName == nil { + break + } + + return e.complexity.MemberSearchResult.FullName(childComplexity), true + + case "MemberSearchResult.id": + if e.complexity.MemberSearchResult.ID == nil { + break + } + + return e.complexity.MemberSearchResult.ID(childComplexity), true + + case "MemberSearchResult.joined": + if e.complexity.MemberSearchResult.Joined == nil { + break + } + + return e.complexity.MemberSearchResult.Joined(childComplexity), true + + case "MemberSearchResult.similarity": + if e.complexity.MemberSearchResult.Similarity == nil { + break + } + + return e.complexity.MemberSearchResult.Similarity(childComplexity), true + + case "MemberSearchResult.username": + if e.complexity.MemberSearchResult.Username == nil { + break + } + + return e.complexity.MemberSearchResult.Username(childComplexity), true + case "Mutation.addTaskLabel": if e.complexity.Mutation.AddTaskLabel == nil { break @@ -1862,6 +1915,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.Projects(childComplexity, args["input"].(*ProjectsFilter)), true + case "Query.searchMembers": + if e.complexity.Query.SearchMembers == nil { + break + } + + args, err := ec.field_Query_searchMembers_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.SearchMembers(childComplexity, args["input"].(MemberSearchFilter)), true + case "Query.taskGroups": if e.complexity.Query.TaskGroups == nil { break @@ -3208,6 +3273,24 @@ extend type Mutation { UpdateUserInfoPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG) } +extend type Query { + searchMembers(input: MemberSearchFilter!): [MemberSearchResult!]! +} + +input MemberSearchFilter { + SearchFilter: String! + projectID: UUID +} + +type MemberSearchResult { + id: UUID! + similarity: Int! + username: String! + fullName: String! + confirmed: Boolean! + joined: Boolean! +} + type UpdateUserInfoPayload { user: UserAccount! } @@ -4101,6 +4184,20 @@ func (ec *executionContext) field_Query_projects_args(ctx context.Context, rawAr return args, nil } +func (ec *executionContext) field_Query_searchMembers_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 MemberSearchFilter + if tmp, ok := rawArgs["input"]; ok { + arg0, err = ec.unmarshalNMemberSearchFilter2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMemberSearchFilter(ctx, tmp) + if err != nil { + return nil, err + } + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field___Type_enumValues_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -5701,6 +5798,210 @@ func (ec *executionContext) _MemberList_projects(ctx context.Context, field grap return ec.marshalNProject2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋdbᚐProjectᚄ(ctx, field.Selections, res) } +func (ec *executionContext) _MemberSearchResult_id(ctx context.Context, field graphql.CollectedField, obj *MemberSearchResult) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "MemberSearchResult", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.ID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(uuid.UUID) + fc.Result = res + return ec.marshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, field.Selections, res) +} + +func (ec *executionContext) _MemberSearchResult_similarity(ctx context.Context, field graphql.CollectedField, obj *MemberSearchResult) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "MemberSearchResult", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Similarity, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(int) + fc.Result = res + return ec.marshalNInt2int(ctx, field.Selections, res) +} + +func (ec *executionContext) _MemberSearchResult_username(ctx context.Context, field graphql.CollectedField, obj *MemberSearchResult) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "MemberSearchResult", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Username, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) _MemberSearchResult_fullName(ctx context.Context, field graphql.CollectedField, obj *MemberSearchResult) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "MemberSearchResult", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.FullName, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) _MemberSearchResult_confirmed(ctx context.Context, field graphql.CollectedField, obj *MemberSearchResult) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "MemberSearchResult", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Confirmed, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) _MemberSearchResult_joined(ctx context.Context, field graphql.CollectedField, obj *MemberSearchResult) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "MemberSearchResult", + Field: field, + Args: nil, + IsMethod: false, + } + + ctx = graphql.WithFieldContext(ctx, fc) + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Joined, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + func (ec *executionContext) _Mutation_createProject(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -10978,6 +11279,47 @@ func (ec *executionContext) _Query_notifications(ctx context.Context, field grap return ec.marshalNNotification2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋdbᚐNotificationᚄ(ctx, field.Selections, res) } +func (ec *executionContext) _Query_searchMembers(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Query", + Field: field, + Args: nil, + IsMethod: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Query_searchMembers_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().SearchMembers(rctx, args["input"].(MemberSearchFilter)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]MemberSearchResult) + fc.Result = res + return ec.marshalNMemberSearchResult2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMemberSearchResultᚄ(ctx, field.Selections, res) +} + func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -15144,6 +15486,30 @@ func (ec *executionContext) unmarshalInputLogoutUser(ctx context.Context, obj in return it, nil } +func (ec *executionContext) unmarshalInputMemberSearchFilter(ctx context.Context, obj interface{}) (MemberSearchFilter, error) { + var it MemberSearchFilter + var asMap = obj.(map[string]interface{}) + + for k, v := range asMap { + switch k { + case "SearchFilter": + var err error + it.SearchFilter, err = ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + case "projectID": + var err error + it.ProjectID, err = ec.unmarshalOUUID2ᚖgithubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v) + if err != nil { + return it, err + } + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputNewProject(ctx context.Context, obj interface{}) (NewProject, error) { var it NewProject var asMap = obj.(map[string]interface{}) @@ -16675,6 +17041,58 @@ func (ec *executionContext) _MemberList(ctx context.Context, sel ast.SelectionSe return out } +var memberSearchResultImplementors = []string{"MemberSearchResult"} + +func (ec *executionContext) _MemberSearchResult(ctx context.Context, sel ast.SelectionSet, obj *MemberSearchResult) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, memberSearchResultImplementors) + + out := graphql.NewFieldSet(fields) + var invalids uint32 + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("MemberSearchResult") + case "id": + out.Values[i] = ec._MemberSearchResult_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "similarity": + out.Values[i] = ec._MemberSearchResult_similarity(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "username": + out.Values[i] = ec._MemberSearchResult_username(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "fullName": + out.Values[i] = ec._MemberSearchResult_fullName(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "confirmed": + out.Values[i] = ec._MemberSearchResult_confirmed(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + case "joined": + out.Values[i] = ec._MemberSearchResult_joined(ctx, field, obj) + if out.Values[i] == graphql.Null { + invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch() + if invalids > 0 { + return graphql.Null + } + return out +} + var mutationImplementors = []string{"Mutation"} func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) graphql.Marshaler { @@ -17645,6 +18063,20 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr } return res }) + case "searchMembers": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_searchMembers(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&invalids, 1) + } + return res + }) case "__type": out.Values[i] = ec._Query___type(ctx, field) case "__schema": @@ -19452,6 +19884,51 @@ func (ec *executionContext) marshalNMemberList2ᚖgithubᚗcomᚋjordanknottᚋt return ec._MemberList(ctx, sel, v) } +func (ec *executionContext) unmarshalNMemberSearchFilter2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMemberSearchFilter(ctx context.Context, v interface{}) (MemberSearchFilter, error) { + return ec.unmarshalInputMemberSearchFilter(ctx, v) +} + +func (ec *executionContext) marshalNMemberSearchResult2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMemberSearchResult(ctx context.Context, sel ast.SelectionSet, v MemberSearchResult) graphql.Marshaler { + return ec._MemberSearchResult(ctx, sel, &v) +} + +func (ec *executionContext) marshalNMemberSearchResult2ᚕgithubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMemberSearchResultᚄ(ctx context.Context, sel ast.SelectionSet, v []MemberSearchResult) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNMemberSearchResult2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐMemberSearchResult(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + return ret +} + func (ec *executionContext) unmarshalNNewProject2githubᚗcomᚋjordanknottᚋtaskcafeᚋinternalᚋgraphᚐNewProject(ctx context.Context, v interface{}) (NewProject, error) { return ec.unmarshalInputNewProject(ctx, v) } diff --git a/internal/graph/models_gen.go b/internal/graph/models_gen.go index c6e8a10..1e89966 100644 --- a/internal/graph/models_gen.go +++ b/internal/graph/models_gen.go @@ -212,6 +212,20 @@ type MemberList struct { Projects []db.Project `json:"projects"` } +type MemberSearchFilter struct { + SearchFilter string `json:"SearchFilter"` + ProjectID *uuid.UUID `json:"projectID"` +} + +type MemberSearchResult struct { + ID uuid.UUID `json:"id"` + Similarity int `json:"similarity"` + Username string `json:"username"` + FullName string `json:"fullName"` + Confirmed bool `json:"confirmed"` + Joined bool `json:"joined"` +} + type NewProject struct { TeamID *uuid.UUID `json:"teamID"` Name string `json:"name"` diff --git a/internal/graph/schema.graphqls b/internal/graph/schema.graphqls index 5e9c68c..e56df79 100644 --- a/internal/graph/schema.graphqls +++ b/internal/graph/schema.graphqls @@ -734,6 +734,24 @@ extend type Mutation { UpdateUserInfoPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG) } +extend type Query { + searchMembers(input: MemberSearchFilter!): [MemberSearchResult!]! +} + +input MemberSearchFilter { + SearchFilter: String! + projectID: UUID +} + +type MemberSearchResult { + id: UUID! + similarity: Int! + username: String! + fullName: String! + confirmed: Boolean! + joined: Boolean! +} + type UpdateUserInfoPayload { user: UserAccount! } diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index 25382cc..f88ff19 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -13,6 +13,7 @@ import ( "github.com/google/uuid" "github.com/jordanknott/taskcafe/internal/auth" "github.com/jordanknott/taskcafe/internal/db" + "github.com/lithammer/fuzzysearch/fuzzy" log "github.com/sirupsen/logrus" "github.com/vektah/gqlparser/v2/gqlerror" "golang.org/x/crypto/bcrypt" @@ -1193,6 +1194,41 @@ func (r *queryResolver) Notifications(ctx context.Context) ([]db.Notification, e return notifications, nil } +func (r *queryResolver) SearchMembers(ctx context.Context, input MemberSearchFilter) ([]MemberSearchResult, error) { + availableMembers, err := r.Repository.GetMemberData(ctx) + if err != nil { + return []MemberSearchResult{}, err + } + + sortList := []string{} + masterList := map[string]uuid.UUID{} + for _, member := range availableMembers { + sortList = append(sortList, member.Username) + sortList = append(sortList, member.Email) + masterList[member.Username] = member.UserID + masterList[member.Email] = member.UserID + } + rankedList := fuzzy.RankFind(input.SearchFilter, sortList) + results := []MemberSearchResult{} + memberList := map[uuid.UUID]bool{} + for _, rank := range rankedList { + if _, ok := memberList[masterList[rank.Target]]; !ok { + log.WithFields(log.Fields{"source": rank.Source, "target": rank.Target}).Info("searching") + userID := masterList[rank.Target] + user, err := r.Repository.GetUserAccountByID(ctx, userID) + if err != nil { + if err == sql.ErrNoRows { + continue + } + return []MemberSearchResult{}, err + } + results = append(results, MemberSearchResult{FullName: user.FullName, Username: user.Username, Joined: false, Confirmed: false, Similarity: rank.Distance, ID: user.UserID}) + memberList[masterList[rank.Target]] = true + } + } + return results, nil +} + func (r *refreshTokenResolver) ID(ctx context.Context, obj *db.RefreshToken) (uuid.UUID, error) { return obj.TokenID, nil } diff --git a/internal/graph/schema/user.gql b/internal/graph/schema/user.gql index 6f85792..5784d90 100644 --- a/internal/graph/schema/user.gql +++ b/internal/graph/schema/user.gql @@ -15,6 +15,24 @@ extend type Mutation { UpdateUserInfoPayload! @hasRole(roles: [ADMIN], level: ORG, type: ORG) } +extend type Query { + searchMembers(input: MemberSearchFilter!): [MemberSearchResult!]! +} + +input MemberSearchFilter { + SearchFilter: String! + projectID: UUID +} + +type MemberSearchResult { + id: UUID! + similarity: Int! + username: String! + fullName: String! + confirmed: Boolean! + joined: Boolean! +} + type UpdateUserInfoPayload { user: UserAccount! }