Skip to content

Commit

Permalink
Implemented Workspace Search & Discovery
Browse files Browse the repository at this point in the history
  • Loading branch information
R1c4rdCo5t4 committed Jun 23, 2024
1 parent 73526d2 commit b90e850
Show file tree
Hide file tree
Showing 21 changed files with 305 additions and 13 deletions.
10 changes: 10 additions & 0 deletions code/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import Home from '@ui/pages/home/Home';
import AuthProvider from '@/contexts/auth/AuthContext';
import Profile from '@ui/pages/profile/Profile';
import Landing from '@ui/pages/landing/Landing';
import Search from '@ui/pages/search/Search';

function App() {
return (
Expand All @@ -34,6 +35,15 @@ function App() {
</>
}
/>
<Route
path="/search"
element={
<>
<Sidebar />
<Search />
</>
}
/>
<Route path="/profile/:id" element={<Profile />} />
<Route
path="/workspaces/*"
Expand Down
3 changes: 1 addition & 2 deletions code/client/src/contexts/auth/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { auth, githubAuthProvider, googleAuthProvider } from '@config';
import { createContext, ReactNode, useEffect, useState } from 'react';
import { signInWithPopup, signOut, User, type AuthProvider as Provider, inMemoryPersistence } from 'firebase/auth';
import { signInWithPopup, signOut, User, type AuthProvider as Provider } from 'firebase/auth';
import useError from '@/contexts/error/useError';
import useAuthService from '@services/auth/useAuthService';
import { useNavigate } from 'react-router-dom';
Expand Down Expand Up @@ -34,7 +34,6 @@ export function AuthProvider({ children }: AuthProviderProps) {

const loginWithProvider = async (provider: Provider) => {
try {
auth.setPersistence(inMemoryPersistence); // for httpOnly cookies, do not persist any state client side
const { user } = await signInWithPopup(auth, provider);
const idToken = await user.getIdToken();
await sessionLogin(idToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { BlockStyle, InlineStyle } from '@notespace/shared/src/document/types/st
import { Selection } from '@domain/editor/cursor';
import { Fugue } from '@domain/editor/fugue/Fugue';
import { Operation } from '@notespace/shared/src/document/types/operations';
import { isSelectionEmpty } from '@domain/editor/slate/utils/selection';
import { isEqual } from 'lodash';
import { Id } from '@notespace/shared/src/document/types/types';
import { deleteAroundSelection } from '@domain/editor/connectors/markdown/utils';
Expand Down
4 changes: 4 additions & 0 deletions code/client/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ button {
padding: 2vh;
}

button:disabled {
cursor: not-allowed;
}

.MuiCheckbox-root {
transition: background-color 0.2s;
display: flex !important;
Expand Down
10 changes: 10 additions & 0 deletions code/client/src/services/workspace/workspaceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ function workspaceService(http: HttpCommunication) {
await http.delete(`/workspaces/${id}/members`, { email });
}

async function getWorkspacesFeed() {
return await http.get('/workspaces/search');
}

async function searchWorkspaces(query: string, skip: number, limit: number): Promise<WorkspaceMeta[]> {
return await http.get(`/workspaces/search?query=${query}&skip=${skip}&limit=${limit}`);
}

return {
getWorkspace,
getWorkspaces,
Expand All @@ -42,6 +50,8 @@ function workspaceService(http: HttpCommunication) {
updateWorkspace,
addWorkspaceMember,
removeWorkspaceMember,
getWorkspacesFeed,
searchWorkspaces,
};
}

Expand Down
24 changes: 24 additions & 0 deletions code/client/src/ui/components/header/Header.scss
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,28 @@
padding: 1vh;
}
}

input {
background-color: transparent;
color: black;
width: 300px;
padding: 0.3rem 0.5rem;
border: 1px solid #bbb;
border-radius: 5px;
transition: all 0.3s ease;
outline: none;
}

input:focus {
border-color: lightgray;
}

input::placeholder {
color: #aaa;
font-style: italic;
}

input:hover {
border-color: #888;
}
}
30 changes: 28 additions & 2 deletions code/client/src/ui/components/header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,39 @@
import './Header.scss';
import { useAuth } from '@/contexts/auth/useAuth';
import { Link } from 'react-router-dom';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { ChangeEvent, FormEvent, useState } from 'react';
import './Header.scss';

function Header() {
const { currentUser, logout } = useAuth();
const [searchInput, setSearchInput] = useState('');
const navigate = useNavigate();
const location = useLocation();

function handleSearchInput(e: ChangeEvent<HTMLInputElement>) {
setSearchInput(e.target.value);
}

function handleSearchSubmit(e: FormEvent) {
e.preventDefault();
navigate(`/search?query=${searchInput}`);
}

return (
<header className="header">
<Link to={currentUser ? '/home' : '/'}>NoteSpace</Link>
<div>
{location.pathname !== '/' && (
<form onSubmit={handleSearchSubmit}>
<input
type="text"
placeholder="Search workspaces..."
onInput={handleSearchInput}
spellCheck={false}
maxLength={20}
/>
</form>
)}

<div className="account">
{currentUser && (
<>
Expand Down
18 changes: 18 additions & 0 deletions code/client/src/ui/components/spinner/Spinner.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.spinner {
border: 8px solid #f3f3f3;
border-top: 8px solid lightgray;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 2s linear infinite;
margin: auto;
}

@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
7 changes: 7 additions & 0 deletions code/client/src/ui/components/spinner/Spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import './Spinner.scss';

function Spinner() {
return <div className="spinner"></div>;
}

export default Spinner;
15 changes: 15 additions & 0 deletions code/client/src/ui/hooks/useLoading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useCallback, useState } from 'react';
import Spinner from '@ui/components/spinner/Spinner';

function useLoading() {
const [loading, setLoading] = useState(false);

const startLoading = useCallback(() => setLoading(true), []);
const stopLoading = useCallback(() => setLoading(false), []);

const spinner = <Spinner />;

return { loading, startLoading, stopLoading, spinner };
}

export default useLoading;
29 changes: 27 additions & 2 deletions code/client/src/ui/pages/home/Home.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,37 @@
import { Link } from 'react-router-dom';
import './Home.scss';
import { WorkspaceMeta } from '@notespace/shared/src/workspace/types/workspace';
import { useEffect, useState } from 'react';
import useWorkspaceService from '@services/workspace/useWorkspaceService';
import useLoading from '@ui/hooks/useLoading';

function Home() {
const [workspaces, setWorkspaces] = useState<WorkspaceMeta[]>([]);
const service = useWorkspaceService();
const { loading, startLoading, stopLoading, spinner } = useLoading();

useEffect(() => {
async function getWorkspaces() {
startLoading();
const workspaces = await service.getWorkspacesFeed();
setWorkspaces(workspaces);
stopLoading();
}
getWorkspaces();
}, [service, startLoading, stopLoading]);

return (
<div className="home">
<h2>Home</h2>
<p>Welcome to the home page</p>
<Link to="/workspaces">Go to Workspaces</Link>
{loading
? spinner
: workspaces.map(workspace => (
<div className="workspace">
<Link key={workspace.id} to={`/workspaces/${workspace.id}`}>
{workspace.name}
</Link>
</div>
))}
</div>
);
}
Expand Down
21 changes: 21 additions & 0 deletions code/client/src/ui/pages/search/Search.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.search {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-evenly;

.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 2vh;
margin-top: auto;
margin-bottom: 10vh;

button {
border-radius: 50%;
background-color: lightgray;
color: black;
}
}
}
47 changes: 47 additions & 0 deletions code/client/src/ui/pages/search/Search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Link } from 'react-router-dom';
import { WorkspaceMeta } from '@notespace/shared/src/workspace/types/workspace';
import { useEffect, useState } from 'react';
import { useQueryParams } from '@/utils/utils';
import useWorkspaceService from '@services/workspace/useWorkspaceService';
import './Search.scss';
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa6';

const PAGE_SIZE = 10;

function Search() {
const [results, setResults] = useState<WorkspaceMeta[]>([]);
const [page, setPage] = useState(0);
const { query } = useQueryParams();
const service = useWorkspaceService();

useEffect(() => {
async function searchWorkspaces() {
const results = await service.searchWorkspaces(query, page * PAGE_SIZE, PAGE_SIZE);
setResults(results);
}
searchWorkspaces();
}, [page, query, service]);

return (
<div className="search">
<h2>Search results for "{query}"</h2>
{results.map(workspace => (
<div className="workspace">
<Link key={workspace.id} to={`/workspace/${workspace.id}`}>
{workspace.name}
</Link>
</div>
))}
<div className="pagination">
<button onClick={() => setPage(page - 1)} disabled={page === 0}>
<FaArrowLeft />
</button>
<button onClick={() => setPage(page + 1)} disabled={results.length < PAGE_SIZE}>
<FaArrowRight />
</button>
</div>
</div>
);
}

export default Search;
9 changes: 9 additions & 0 deletions code/client/src/utils/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useLocation } from 'react-router-dom';

export function formatDate(isoString: string) {
const date = new Date(isoString);
return date.toLocaleDateString('en-US', {
Expand Down Expand Up @@ -34,3 +36,10 @@ export function formatTimePassed(isoString: string): string {
return seconds <= 0 ? 'Just now' : formatTime(seconds, 'second');
}
}

export function useQueryParams() {
const location = useLocation();
const params = new URLSearchParams(location.search);
const entries = Array.from(params.entries());
return Object.fromEntries(entries);
}
13 changes: 11 additions & 2 deletions code/server/src/controllers/http/handlers/workspacesHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Services } from '@services/Services';
import { Server } from 'socket.io';
import { ForbiddenError, InvalidParameterError } from '@domain/errors/errors';
import { enforceAuth } from '@controllers/http/middlewares/authMiddlewares';
import { getSearchParams, SearchParams } from '@src/utils/searchParams';

function workspacesHandlers(services: Services, io: Server) {
const createWorkspace = async (req: Request, res: Response) => {
Expand All @@ -28,7 +29,7 @@ function workspacesHandlers(services: Services, io: Server) {
};

const getWorkspaces = async (req: Request, res: Response) => {
const workspaces = await services.workspaces.getWorkspaces(req.user?.id || '');
const workspaces = await services.workspaces.getWorkspaces(req.user!.id);
httpResponse.ok(res).json(workspaces);
};

Expand Down Expand Up @@ -84,6 +85,13 @@ function workspacesHandlers(services: Services, io: Server) {
httpResponse.noContent(res).send();
};

const searchWorkspaces = async (req: Request, res: Response) => {
const { query, skip, limit } = req.query;
const searchParams: SearchParams = getSearchParams({ query, skip, limit });
const workspaces = await services.workspaces.searchWorkspaces(searchParams);
httpResponse.ok(res).json(workspaces);
};

async function getWorkspacePermissions(id: string, userEmail: string) {
const workspace = await services.workspaces.getWorkspace(id);
if (!workspace) throw new InvalidParameterError('Workspace not found');
Expand Down Expand Up @@ -113,7 +121,8 @@ function workspacesHandlers(services: Services, io: Server) {

const router = PromiseRouter();
router.post('/', enforceAuth, createWorkspace);
router.get('/', getWorkspaces);
router.get('/', enforceAuth, getWorkspaces);
router.get('/search', searchWorkspaces);
router.get('/:wid', workspaceReadPermission, getWorkspace);
router.put('/:wid', enforceAuth, workspaceWritePermission, updateWorkspace);
router.delete('/:wid', enforceAuth, workspaceWritePermission, deleteWorkspace);
Expand Down
15 changes: 15 additions & 0 deletions code/server/src/databases/memory/MemoryWorkspacesDB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { v4 as uuid } from 'uuid';
import { Resource, ResourceType } from '@notespace/shared/src/workspace/types/resource';
import { omit } from 'lodash';
import { NotFoundError } from '@domain/errors/errors';
import { SearchParams } from '@src/utils/searchParams';

export class MemoryWorkspacesDB implements WorkspacesRepository {
constructor() {
Expand Down Expand Up @@ -81,4 +82,18 @@ export class MemoryWorkspacesDB implements WorkspacesRepository {
const workspace = Memory.workspaces[wid];
return workspace.members?.map(userId => Memory.users[userId].email) || [];
}

async searchWorkspaces(searchParams: SearchParams): Promise<WorkspaceMeta[]> {
const { query, skip, limit } = searchParams;
return Object.values(Memory.workspaces)
.filter(workspace => !workspace.isPrivate) // public workspaces
.filter(workspace => (query ? workspace.name.toLowerCase().includes(query.toLowerCase()) : true)) // search by name
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) // sort results by creation date (newest first)
.slice(skip, skip + limit) // paginate results
.map(workspace => ({
// convert to WorkspaceMeta
...omit(workspace, ['resources']),
members: workspace.members?.length || 0,
}));
}
}
Loading

0 comments on commit b90e850

Please sign in to comment.