Skip to content

Commit

Permalink
feat: cluster search engine (#193)
Browse files Browse the repository at this point in the history
* fix: loader mismatch state between server and client

* chore: add more detail about certificates

* feat: create conditional wrapper component

* feat: implement redirect link on location badge

* fix: resolve app version on sidebar

* feat: implement cluster finder across all campus

* chore: run linter

* chore: refactor the cluster map implementation

* feat: search only on online users

* feat: search component reusability

* chore: refactor loader to separate spinner

* feat: use the new search engine
  • Loading branch information
42atomys committed Jul 23, 2022
1 parent ce687de commit 926dccd
Show file tree
Hide file tree
Showing 27 changed files with 428 additions and 143 deletions.
3 changes: 2 additions & 1 deletion api/graphs/api.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ type LocationEdge {

type Query {
me: User! @authenticated
searchUser(query: String!): [User!]! @authenticated
searchUser(query: String!, onlyOnline: Boolean = false): [User!]!
@authenticated

campus(id: UUID!): Campus @authenticated
user(id: UUID!): User @authenticated
Expand Down
4 changes: 3 additions & 1 deletion deploy/cluster/cert-manager/certificates/app.s42.next.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ metadata:
spec:
dnsNames:
- next.s42.app
- '*.next.s42.app'
- "*.next.s42.app"
duration: 2160h # 90d
renewBefore: 360h # 15d
issuerRef:
kind: ClusterIssuer
name: ovh-issuer
Expand Down
4 changes: 3 additions & 1 deletion deploy/cluster/cert-manager/certificates/app.s42.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ metadata:
spec:
dnsNames:
- s42.app
- '*.s42.app'
- "*.s42.app"
duration: 2160h # 90d
renewBefore: 360h # 15d
issuerRef:
kind: ClusterIssuer
name: ovh-issuer
Expand Down
8 changes: 5 additions & 3 deletions deploy/cluster/cert-manager/certificates/dev.s42.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ metadata:
spec:
dnsNames:
- s42.dev
- '*.s42.dev'
- '*.sandbox.s42.dev'
- '*.reviews.s42.dev'
- "*.s42.dev"
- "*.sandbox.s42.dev"
- "*.reviews.s42.dev"
duration: 2160h # 90d
renewBefore: 360h # 15d
issuerRef:
kind: ClusterIssuer
name: ovh-issuer
Expand Down
64 changes: 40 additions & 24 deletions internal/api/api.resolvers.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,33 +121,49 @@ func (r *queryResolver) Me(ctx context.Context) (*generated.User, error) {
return CurrentUserFromContext(ctx)
}

func (r *queryResolver) SearchUser(ctx context.Context, query string) ([]*generated.User, error) {
func (r *queryResolver) SearchUser(ctx context.Context, query string, onlyOnline *bool) ([]*generated.User, error) {
cu, _ := CurrentUserFromContext(ctx)

return r.client.User.Query().
Where(func(s *sql.Selector) {
t := sql.Table(user.Table)

s.Select(t.Columns(user.Columns...)...).
From(t).
Where(
sql.And(
sql.NEQ(t.C(user.FieldID), cu.ID),
sql.Like(t.C(user.FieldDuoLogin), query),
),
).
UnionAll(
sql.Select(t.Columns(user.Columns...)...).
From(t).
Where(
sql.And(
sql.NEQ(t.C(user.FieldID), cu.ID),
sql.ExprP("CONCAT(COALESCE(NULLIF(TRIM(usual_first_name), ''), first_name), ' ', last_name) ILIKE $4", fmt.Sprintf("%%%s%%", utils.StringLimiter(query, 20))),
),
sqlQuery := r.client.User.Query()

if onlyOnline != nil && *onlyOnline {
sqlQuery = sqlQuery.WithCurrentLocation(func(lq *generated.LocationQuery) {
lq.WithCampus()
})
}

return sqlQuery.Where(func(s *sql.Selector) {
t := sql.Table(user.Table)

// predicates to know if the user is online
var onlinePredicate *sql.Predicate
if onlyOnline != nil && *onlyOnline {
onlinePredicate = sql.NotNull(t.C(user.FieldCurrentLocationID))
} else {
onlinePredicate = sql.IsNull(t.C(user.FieldCurrentLocationID))
}

s.Select(t.Columns(user.Columns...)...).
From(t).
Where(
sql.And(
sql.NEQ(t.C(user.FieldID), cu.ID),
sql.Like(t.C(user.FieldDuoLogin), query),
onlinePredicate,
),
).
UnionAll(
sql.Select(t.Columns(user.Columns...)...).
From(t).
Where(
sql.And(
sql.NEQ(t.C(user.FieldID), cu.ID),
onlinePredicate,
sql.ExprP("CONCAT(COALESCE(NULLIF(TRIM(usual_first_name), ''), first_name), ' ', last_name) ILIKE $4", fmt.Sprintf("%%%s%%", utils.StringLimiter(query, 20))),
),
)
}).
Limit(10).All(ctx)
),
)
}).Limit(10).All(ctx)
}

func (r *queryResolver) Campus(ctx context.Context, id uuid.UUID) (*generated.Campus, error) {
Expand Down
3 changes: 3 additions & 0 deletions web/ui/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ const nextConfig = {
},
reactStrictMode: true,
poweredByHeader: false,
publicRuntimeConfig: {
app_version: process.env.APP_VERSION,
},

output: 'standalone',
sassOptions: {
Expand Down
59 changes: 41 additions & 18 deletions web/ui/src/components/Badge/LocationBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import ConditionalWrapper from '@components/ConditionalWrapper';
import Emoji from '@components/Emoji';
import { Location } from '@graphql.d';
import { clusterURL } from '@lib/searchEngine';
import classNames from 'classnames';
import Link from 'next/link';
import { NestedPartial } from 'types/utils';
import { Badge } from './Badge';
import { countryNameToEmoji } from './countryMap';
Expand All @@ -13,24 +16,44 @@ export const LocationBadge = ({
const isConnected = location?.identifier ? true : false;

return (
<Badge color={isConnected ? 'green' : 'gray'}>
<span
className={classNames(
'inline-flex rounded-full w-2 h-2',
isConnected ? 'bg-emerald-500' : 'bg-slate-500'
<ConditionalWrapper
// `? true : false` mysterious workaround to prevent ts error
condition={location?.campus?.name && location?.identifier ? true : false}
trueWrapper={(children) => {
const url = clusterURL(
location?.campus?.name as string,
location?.identifier as string
);
if (!url) {
return <></>;
}

return (
<Link href={url}>
<a>{children}</a>
</Link>
);
}}
>
<Badge color={isConnected ? 'green' : 'gray'}>
<span
className={classNames(
'inline-flex rounded-full w-2 h-2',
isConnected ? 'bg-emerald-500' : 'bg-slate-500'
)}
></span>
<span className="flex flex-row justify-center items-center text-sm mx-1">
{isConnected ? location?.identifier : 'Offline'}
</span>
{isConnected && (
<Emoji
emoji={countryNameToEmoji[location?.campus?.country || '']}
size={14}
title={location?.campus?.name}
className={classNames('mx-1', isConnected ? 'visible' : 'hidden')}
/>
)}
></span>
<span className="flex flex-row justify-center items-center text-sm mx-1">
{isConnected ? location?.identifier : 'Offline'}
</span>
{isConnected && (
<Emoji
emoji={countryNameToEmoji[location?.campus?.country || '']}
size={14}
title={location?.campus?.name}
className={classNames('mx-1', isConnected ? 'visible' : 'hidden')}
/>
)}
</Badge>
</Badge>
</ConditionalWrapper>
);
};
50 changes: 45 additions & 5 deletions web/ui/src/components/ClusterMap/ClusterContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import Loader from '@components/Loader';
import useSidebar from '@components/Sidebar';
import { UserPopup, PopupConsumer, PopupProvider } from '@components/UserPopup';
import { PopupConsumer, PopupProvider, UserPopup } from '@components/UserPopup';
import { useClusterViewQuery, User } from '@graphql.d';
import { isFirstLoading } from '@lib/apollo';
import { useRouter } from 'next/router';
import { createContext, useEffect, useState } from 'react';
import { ClusterSidebar } from '../../containers/clusters/ClusterSidebar';
import { ClusterContainerComponent } from './types';
import { ClusterContainerComponent, ClusterContextInterface } from './types';

/**
* ClusterContext is a react context that holds the current cluster
* information. It is used to share the cluster information between the
* ClusterContainer and the ClusterMap components.
*/
export const ClusterContext = createContext<ClusterContextInterface>({
highlight: false,
hightlightVisibility: () => 'DIMMED',
});

/**
* ClusterContainer component is used to display the cluster map and the cluster
Expand Down Expand Up @@ -34,16 +46,44 @@ export const ClusterContainer: ClusterContainerComponent = ({
children,
}) => {
const { SidebarProvider, PageContainer, PageContent } = useSidebar();
const {
asPath,
query: { identifier: highlightedIdentifier },
replace,
} = useRouter();
const [highlight, setHighlight] = useState(false);
const { data, networkStatus, error } = useClusterViewQuery({
variables: { campusName: campus, identifierPrefix: cluster },
});

useEffect(() => {
if (highlightedIdentifier) {
replace(asPath.split('?')[0], undefined, { shallow: true });
setHighlight(true);
const timer = setTimeout(() => setHighlight(false), 5000);

return () => clearTimeout(timer);
}
// Workaround due to this bug
// https://github.com/vercel/next.js/issues/18127#issuecomment-950907739
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [highlightedIdentifier]);

return (
<SidebarProvider>
<PageContainer>
<PopupProvider>
<>
<ClusterSidebar campus="paris" cluster={cluster as string} />
<ClusterContext.Provider
value={{
highlight: highlight,
hightlightVisibility: (identifier) =>
highlightedIdentifier === identifier ? 'HIGHLIGHT' : 'DIMMED',
}}
>
<ClusterSidebar
campus={campus.toLowerCase()}
cluster={cluster as string}
/>
<PageContent
className={
'p-2 flex-1 flex justify-center min-h-screen items-center'
Expand Down Expand Up @@ -72,7 +112,7 @@ export const ClusterContainer: ClusterContainerComponent = ({
</PopupConsumer>
)}
</PageContent>
</>
</ClusterContext.Provider>
</PopupProvider>
</PageContainer>
</SidebarProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import Avatar from '@components/Avatar';
import classNames from 'classnames';
import { Children } from 'react';
import { ClusterContext } from './ClusterContainer';
import { MapLocation } from './types';

/**
* ClusterMap component is used to display a cluster map with a table style
* ClusterTableMap component is used to display a cluster map with a table style
* like Paris maps.
*/
export const ClusterMap = ({
export const ClusterTableMap = ({
children,
}: {
children: React.ReactNode[] | React.ReactNode;
Expand Down Expand Up @@ -51,31 +52,42 @@ export const ClusterWorkspaceWithUser = ({
) => void;
}) => {
return (
<div
className={classNames(
'flex flex-1 flex-col justify-center items-center m-0.5 rounded text-slate-500',
location.user.isMe
? 'cursor-pointer bg-cyan-300/60 dark:bg-cyan-700/60 text-cyan-500'
: location.user.isFollowing
? 'cursor-pointer bg-blue-300/60 dark:bg-blue-700/60 text-blue-500'
: location.user.isSwimmer
? 'cursor-pointer bg-yellow-300/30 dark:bg-yellow-700/30 text-yellow-500'
: 'cursor-pointer bg-emerald-300/30 dark:bg-emerald-700/30 text-emerald-500'
<ClusterContext.Consumer>
{({ highlight, hightlightVisibility }) => (
<div
className={classNames(
'flex flex-1 flex-col justify-center items-center m-0.5 rounded text-slate-500 cursor-pointer transition ease-in-out duration-200',
location.user.isMe
? 'bg-cyan-300/60 dark:bg-cyan-700/60 text-cyan-500'
: location.user.isFollowing
? 'bg-blue-300/60 dark:bg-blue-700/60 text-blue-500'
: location.user.isSwimmer
? 'bg-yellow-300/30 dark:bg-yellow-700/30 text-yellow-500'
: 'bg-emerald-300/30 dark:bg-emerald-700/30 text-emerald-500',
highlight &&
hightlightVisibility(location.identifier) == 'HIGHLIGHT'
? '!bg-indigo-500 shadow-sm shadow-indigo-500/50 !text-slate-100'
: '',
highlight && hightlightVisibility(location.identifier) == 'DIMMED'
? 'opacity-30'
: 'opacity-100'
)}
onClick={(e) => onClick && onClick(e, location)}
onMouseEnter={(e) => onMouseEnter && onMouseEnter(e, location)}
onMouseLeave={(e) => onMouseLeave && onMouseLeave(e, location)}
>
<span className="mb-1">
<Avatar
login={location.user.duoLogin}
duoAvatarURL={location.user.duoAvatarURL}
rounded={false}
size="md"
/>
</span>
<span className="text-xs">{displayText || location.identifier}</span>
</div>
)}
onClick={(e) => onClick && onClick(e, location)}
onMouseEnter={(e) => onMouseEnter && onMouseEnter(e, location)}
onMouseLeave={(e) => onMouseLeave && onMouseLeave(e, location)}
>
<span className="mb-1">
<Avatar
login={location.user.duoLogin}
duoAvatarURL={location.user.duoAvatarURL}
rounded={false}
size="md"
/>
</span>
<span className="text-xs">{displayText || location.identifier}</span>
</div>
</ClusterContext.Consumer>
);
};

Expand Down
12 changes: 5 additions & 7 deletions web/ui/src/components/ClusterMap/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
export {
ClusterMap,
ClusterEmpty,
ClusterPillar,
ClusterRow,
ClusterTableMap,
ClusterWorkspace,
ClusterWorkspaceWithUser,
ClusterPillar,
ClusterEmpty,
} from './ClusterMap';

export { extractNode, extractandRemoveNode } from './utils';

} from './ClusterTableMap';
export type { MapLocation } from './types';
export { extractandRemoveNode, extractNode } from './utils';
9 changes: 9 additions & 0 deletions web/ui/src/components/ClusterMap/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,12 @@ export type ClusterContainerProps = {
}[keyof CampusClusterMap];

type ClusterContainerComponent = (props: ClusterContainerProps) => JSX.Element;

// Represents that state made available via this reducer
type ClusterState = {
highlight: boolean;
hightlightVisibility: (identifier: string) => 'HIGHLIGHT' | 'DIMMED';
};

// This is what our PopupContext will be expecting as its value prop.
type ClusterContextInterface = readonly ClusterState;
Loading

0 comments on commit 926dccd

Please sign in to comment.