Add GQL query for projects search in app (#38)

* Find projects based on search text

* Use get search projects client method in UI

* Fetch searched projects inside useCombobox hook

* Get searched project from project entity

* Remove non required search projects filtering

* Fetch projects if user is owner or project member

---------

Co-authored-by: neeraj <neeraj.rtly@gmail.com>
This commit is contained in:
Nabarun Gogoi 2024-01-24 20:17:43 +05:30 committed by Ashwin Phatak
parent cbc394f9f8
commit c2b997a17b
13 changed files with 138 additions and 28 deletions

View File

@ -190,4 +190,20 @@ export class Database {
return projectMemberWithProject[0]; return projectMemberWithProject[0];
} }
async getProjectsBySearchText (userId: number, searchText: string): Promise<Project[]> {
const projectRepository = this.dataSource.getRepository(Project);
const projects = await projectRepository
.createQueryBuilder('project')
.leftJoinAndSelect('project.organization', 'organization')
.leftJoin('project.projectMembers', 'projectMembers')
.where('(project.owner = :userId OR projectMembers.member.id = :userId) AND project.name LIKE :searchText', {
userId,
searchText: `%${searchText}%`
})
.getMany();
return projects;
}
} }

View File

@ -5,11 +5,13 @@ import {
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, UpdateDateColumn,
ManyToOne, ManyToOne,
JoinColumn JoinColumn,
OneToMany
} from 'typeorm'; } from 'typeorm';
import { User } from './User'; import { User } from './User';
import { Organization } from './Organization'; import { Organization } from './Organization';
import { ProjectMember } from './ProjectMember';
@Entity() @Entity()
export class Project { export class Project {
@ -52,4 +54,7 @@ export class Project {
@UpdateDateColumn() @UpdateDateColumn()
updatedAt!: Date; updatedAt!: Date;
@OneToMany(() => ProjectMember, projectMember => projectMember.project)
projectMembers!: ProjectMember[];
} }

View File

@ -21,11 +21,11 @@ export class ProjectMember {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: number; id!: number;
@ManyToOne(() => User, { onDelete: 'CASCADE' }) @ManyToOne(() => User, (user) => user.projectMembers, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' }) @JoinColumn({ name: 'userId' })
member!: User; member!: User;
@ManyToOne(() => Project, { onDelete: 'CASCADE' }) @ManyToOne(() => Project, (project) => project.projectMembers, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'projectId' }) @JoinColumn({ name: 'projectId' })
project!: Project; project!: Project;

View File

@ -2,8 +2,10 @@ import {
Entity, Entity,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Column, Column,
CreateDateColumn CreateDateColumn,
OneToMany
} from 'typeorm'; } from 'typeorm';
import { ProjectMember } from './ProjectMember';
@Entity() @Entity()
export class User { export class User {
@ -21,4 +23,7 @@ export class User {
@CreateDateColumn() @CreateDateColumn()
updatedAt!: Date; updatedAt!: Date;
@OneToMany(() => ProjectMember, projectMember => projectMember.project)
projectMembers!: ProjectMember[];
} }

View File

@ -66,6 +66,16 @@ export const createResolvers = async (db: Database): Promise<any> => {
}); });
return projectMembers; return projectMembers;
},
searchProjects: async (_: any, { searchText }: { searchText: string }, context: any) => {
const dbProjects = await db.getProjectsBySearchText(context.userId, searchText);
const projects = dbProjects.map((project) => {
return projectToGqlType(project, [], []);
});
return projects;
} }
}, },

View File

@ -68,6 +68,7 @@ type Project {
environmentVariables: [EnvironmentVariable!] environmentVariables: [EnvironmentVariable!]
createdAt: String! createdAt: String!
updatedAt: String! updatedAt: String!
organization: Organization!
} }
type ProjectMember { type ProjectMember {
@ -116,6 +117,7 @@ type Query {
projects: [Project!] projects: [Project!]
deployments(projectId: String!): [Deployment!] deployments(projectId: String!): [Deployment!]
projectMembers(projectId: String!): [ProjectMember!] projectMembers(projectId: String!): [ProjectMember!]
searchProjects(searchText: String!): [Project!]
} }
type Mutation { type Mutation {

View File

@ -39,7 +39,8 @@ export const projectToGqlType = (dbProject: Project, projectMembers: ProjectMemb
members: projectMembers, members: projectMembers,
environmentVariables: environmentVariables, environmentVariables: environmentVariables,
createdAt: dbProject.createdAt, createdAt: dbProject.createdAt,
updatedAt: dbProject.updatedAt updatedAt: dbProject.updatedAt,
organization: dbProject.organization
}; };
}; };

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useCombobox } from 'downshift'; import { useCombobox } from 'downshift';
import { import {
@ -9,17 +9,19 @@ import {
Typography, Typography,
} from '@material-tailwind/react'; } from '@material-tailwind/react';
import { Project } from 'gql-client';
import SearchBar from '../SearchBar'; import SearchBar from '../SearchBar';
import { ProjectDetails } from '../../types/project'; import { useGQLClient } from '../../context/GQLClientContext';
interface ProjectsSearchProps { interface ProjectsSearchProps {
projects: ProjectDetails[]; onChange?: (data: Project) => void;
onChange?: (data: ProjectDetails) => void;
} }
const ProjectSearchBar = ({ projects, onChange }: ProjectsSearchProps) => { const ProjectSearchBar = ({ onChange }: ProjectsSearchProps) => {
const [items, setItems] = useState<ProjectDetails[]>([]); const [items, setItems] = useState<Project[]>([]);
const [selectedItem, setSelectedItem] = useState<ProjectDetails | null>(null); const [selectedItem, setSelectedItem] = useState<Project | null>(null);
const client = useGQLClient();
const { const {
isOpen, isOpen,
@ -30,17 +32,14 @@ const ProjectSearchBar = ({ projects, onChange }: ProjectsSearchProps) => {
inputValue, inputValue,
} = useCombobox({ } = useCombobox({
onInputValueChange({ inputValue }) { onInputValueChange({ inputValue }) {
setItems( if (inputValue) {
inputValue // TODO: Use debounce
? projects.filter((project) => fetchProjects(inputValue);
project.title.toLowerCase().includes(inputValue.toLowerCase()), }
)
: [],
);
}, },
items, items,
itemToString(item) { itemToString(item) {
return item ? item.title : ''; return item ? item.name : '';
}, },
selectedItem, selectedItem,
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => { onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
@ -54,6 +53,14 @@ const ProjectSearchBar = ({ projects, onChange }: ProjectsSearchProps) => {
}, },
}); });
const fetchProjects = useCallback(
async (inputValue: string) => {
const { searchProjects } = await client.searchProjects(inputValue);
setItems(searchProjects);
},
[client],
);
return ( return (
<div className="relative"> <div className="relative">
<SearchBar {...getInputProps()} /> <SearchBar {...getInputProps()} />
@ -81,14 +88,14 @@ const ProjectSearchBar = ({ projects, onChange }: ProjectsSearchProps) => {
</ListItemPrefix> </ListItemPrefix>
<div> <div>
<Typography variant="h6" color="blue-gray"> <Typography variant="h6" color="blue-gray">
{item.title} {item.name}
</Typography> </Typography>
<Typography <Typography
variant="small" variant="small"
color="gray" color="gray"
className="font-normal" className="font-normal"
> >
{item.organization} {item.organization.name}
</Typography> </Typography>
</div> </div>
</ListItem> </ListItem>

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet, useNavigate } from 'react-router-dom';
import HorizontalLine from '../components/HorizontalLine'; import HorizontalLine from '../components/HorizontalLine';
import { IconButton, Typography } from '@material-tailwind/react'; import { IconButton, Typography } from '@material-tailwind/react';
@ -9,6 +9,7 @@ import { Environments, ProjectDetails } from '../types/project';
const ProjectSearch = () => { const ProjectSearch = () => {
const client = useGQLClient(); const client = useGQLClient();
const navigate = useNavigate();
const [projects, setProjects] = useState<ProjectDetails[]>([]); const [projects, setProjects] = useState<ProjectDetails[]>([]);
useEffect(() => { useEffect(() => {
@ -87,7 +88,11 @@ const ProjectSearch = () => {
<div className="sticky top-0 bg-white z-30"> <div className="sticky top-0 bg-white z-30">
<div className="flex p-5"> <div className="flex p-5">
<div className="grow mr-2"> <div className="grow mr-2">
<ProjectSearchBar onChange={() => {}} projects={projects} /> <ProjectSearchBar
onChange={(project) => {
navigate(`/projects/${project.id}`);
}}
/>
</div> </div>
<IconButton color="blue" className="rounded-full mr-2"> <IconButton color="blue" className="rounded-full mr-2">
<Typography variant="h5">+</Typography> <Typography variant="h5">+</Typography>

View File

@ -1 +1,2 @@
export * from './src/client'; export * from './src/client';
export * from './src/types';

View File

@ -1,7 +1,7 @@
import { ApolloClient, DefaultOptions, InMemoryCache, NormalizedCacheObject } from '@apollo/client'; import { ApolloClient, DefaultOptions, InMemoryCache, NormalizedCacheObject } from '@apollo/client';
import { getUser, getOrganizations, getDeployments, getProjectMembers } from './queries'; import { getUser, getOrganizations, getDeployments, getProjectMembers, searchProjects } from './queries';
import { GetDeploymentsResponse, GetOrganizationsResponse, GetProjectMembersResponse, GetUserResponse, RemoveMemberResponse } from './types'; import { GetDeploymentsResponse, GetOrganizationsResponse, GetProjectMembersResponse, SearchProjectsResponse, GetUserResponse, RemoveMemberResponse } from './types';
import { removeMember } from './mutations'; import { removeMember } from './mutations';
export interface GraphQLConfig { export interface GraphQLConfig {
@ -79,4 +79,15 @@ export class GQLClient {
return data; return data;
} }
async searchProjects (searchText: string) : Promise<SearchProjectsResponse> {
const { data } = await this.client.query({
query: searchProjects,
variables: {
searchText
}
});
return data;
}
} }

View File

@ -97,3 +97,28 @@ query ($projectId: String!) {
} }
} }
`; `;
export const searchProjects = gql`
query ($searchText: String!) {
searchProjects(searchText: $searchText) {
id
name
prodBranch
repository
createdAt
description
framework
prodBranch
webhooks
updatedAt
template
repository
organization {
id
name
createdAt
updatedAt
}
}
}
`;

View File

@ -84,7 +84,7 @@ export type ProjectMember = {
updatedAt: string updatedAt: string
} }
export type Project = { export type OrganizationProject = {
id: string id: string
owner: User owner: User
deployments: Deployment[] deployments: Deployment[]
@ -104,12 +104,30 @@ export type Project = {
export type Organization = { export type Organization = {
id: string id: string
name: string name: string
projects: Project[] projects: OrganizationProject[]
createdAt: string createdAt: string
updatedAt: string updatedAt: string
members: OrganizationMember[] members: OrganizationMember[]
} }
export type Project = {
id: string
owner: User
deployments: Deployment[]
name: string
repository: string
prodBranch: string
description: string
template: string
framework: string
webhooks: string[]
members: ProjectMember[]
environmentVariables: EnvironmentVariable[]
createdAt: string
updatedAt: string
organization: Organization
}
export type GetProjectMembersResponse = { export type GetProjectMembersResponse = {
projectMembers: ProjectMember[] projectMembers: ProjectMember[]
} }
@ -129,3 +147,7 @@ export type GetOrganizationsResponse = {
export type GetUserResponse = { export type GetUserResponse = {
user: User user: User
} }
export type SearchProjectsResponse = {
searchProjects: Project[]
}