Skip to content

Commit

Permalink
refactor: campus management with generators (#530)
Browse files Browse the repository at this point in the history
**Describe the pull request**

Previously, our campus management system followed a simplistic naming
convention that could not accommodate more intricate naming patterns.
This refactor provides the flexibility to adopt complex naming schemes,
ensuring our system remains adaptive and accommodating to diverse naming
requirements like spacing name or special characters.

**Checklist**

- [x] I have made the modifications or added tests related to my PR
- [x] I have run the tests and linters locally and they pass
- [x] I have added/updated the documentation for my RP

---------

Signed-off-by: Atomys <contact@atomys.fr>
  • Loading branch information
42atomys authored Oct 19, 2023
1 parent 17c6811 commit d00ba9c
Show file tree
Hide file tree
Showing 30 changed files with 473 additions and 178 deletions.
99 changes: 99 additions & 0 deletions web/ui/generators/generateCampusTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
const fs = require('fs');
const path = require('path');
const c = require('console');
require('../src/lib/prototypes/string');

// Generic warning to add at the top of each the generated files
const GeneratedWarning = `// DO NOT EDIT THIS FILE MANUALLY - IT IS GENERATED FROM THE CONTENT OF THE CAMPUS FOLDER\n// RUN \`yarn generate:campus\` TO REGENERATE IT\n\n`;

// Paths to the files used in the generation
const rootPath = path.join(__dirname, '../src/lib/clustersMap');
const directoryPath = path.join(rootPath, './campus');

/**
* generateCampusTypes generates the types.generated.ts file containing the
* CampusIdentifier enum. This enum is used to identify the campus in the
* interface.
* @param campusIdentifiers list of campus identifiers (e.g. ['helsinki', 'paris', ...])
*/
const generateCampusTypes = (campusIdentifiers: any[]): void => {
c.log('⠙ Generate campus types...');

const enumContent = `${GeneratedWarning}/**
* List of all campus names present in the interface as their identifier.
* Identifier must be in camelCase without spaces or special characters. It
* must be unique in the list.
*/
export type CampusIdentifier =
${campusIdentifiers.map((id) => ` | '${id}'`).join('\n')};
`;

fs.writeFileSync(path.join(rootPath, 'types.generated.d.ts'), enumContent);

c.log('✔ Generated campus types.generated.ts file successfully!');
};

/**
* generateCampusClasses generates the campuses.generated.ts file containing the
* CampusIdentifier enum. This enum is used to identify the campus in the
* interface.
* @param campusIdentifiers list of campus identifiers (e.g. ['helsinki', 'paris', ...])
*/
const generateCampusClasses = (campusIdentifiers: any[]): void => {
c.log('⠙ Generate campus types...');

const campusesContent = `${GeneratedWarning}import { ICampus } from './types';
import { CampusIdentifier } from './types.generated';
${campusIdentifiers
.map((id) => `import { ${id.toTitleCase()} } from './campus/${id}';`)
.join('\n')}
/**
* Campuses represents the list of campuses present in the application.
* Particulary, used in the cluster map.
*
* It is a const, so it can be accessed from anywhere in the application.
* You can add a new campus by define the campus in the \`campus\` folder
* (see \`campus/paris.ts\` for an example) and run \`yarn generate:campus\`
*/
export const Campuses: Record<CampusIdentifier, ICampus> = {
${campusIdentifiers
.map((id) => ` ${id}: new ${id.toTitleCase()}(),`)
.join('\n')}
};
`;

fs.writeFileSync(
path.join(rootPath, 'campuses.generated.ts'),
campusesContent,
);

c.log('✔ Generated campuses.generated.ts file successfully!');
};

/**
* Read the campus directory and generate the types and classes files.
* The campus directory contains a file for each campus. The file name is the
* campus identifier. The file contains the class definition of the campus.
* The class must implement the ICampus interface.
* The campus identifier is used to identify the campus in the interface.
* The campus identifier must be in camelCase without spaces or special
* characters. It must be unique in the list.
*/
c.log('⠙ Read campus directory...');
fs.readdir(directoryPath, (err: any, files: any[]) => {
if (err) {
c.error('cannot read directory:', err);
return;
}

const campusIdentifiers = files
.filter((file) => path.extname(file) === '.ts')
.map((file) => path.basename(file, path.extname(file)));

generateCampusTypes(campusIdentifiers);
generateCampusClasses(campusIdentifiers);
});

c.log('✔ Generated campus successfully!');
1 change: 1 addition & 0 deletions web/ui/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
// Used for __tests__/testing-library.js
// Learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';
import './src/lib/prototypes/string';
3 changes: 2 additions & 1 deletion web/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
"node": ">=18.0.0"
},
"scripts": {
"generate": "yarn run generate:gql",
"generate": "yarn run generate:gql && yarn run generate:campus",
"generate:gql": "graphql-codegen --config graphqlcodegen.yml",
"generate:campus": "ts-node generators/generateCampusTypes.ts",
"dev": "next dev",
"dev:watch": "concurrently \"yarn run dev\" \"yarn run generate --watch\"",
"build": "next build",
Expand Down
10 changes: 5 additions & 5 deletions web/ui/src/components/ClusterMap/ClusterContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ export const ClusterContainer: ClusterContainerComponent = ({
} = useRouter();
const [highlight, setHighlight] = useState(false);
const { data, error, networkStatus } = useClusterViewQuery({
variables: { campusName: campus, identifierPrefix: cluster },
variables: {
campusName: campus.name(),
identifierPrefix: cluster.identifier(),
},
fetchPolicy: 'network-only',
// This is a workaround due to missing websocket implementation.
// TODO: Remove this when websocket is implemented.
Expand Down Expand Up @@ -87,10 +90,7 @@ export const ClusterContainer: ClusterContainerComponent = ({
highlightedIdentifier === identifier ? 'HIGHLIGHT' : 'DIMMED',
}}
>
<ClusterSidebar
activeCampusName={campus}
activeClusterIdentifier={cluster}
/>
<ClusterSidebar activeCampus={campus} activeCluster={cluster} />
<PageContent
className={
'p-2 flex-1 flex justify-center min-h-screen items-center'
Expand Down
13 changes: 5 additions & 8 deletions web/ui/src/components/ClusterMap/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Actions, PayloadOf } from '@components/UserPopup';
import type { ClusterViewQuery } from '@graphql.d';
import { CampusNames } from '@lib/clustersMap';
import { NonNullable } from 'types/utils';
import { ICampus, ICluster } from '@lib/clustersMap';

// ClusterMap.tsx
export type MapLocation = NonNullable<
Expand Down Expand Up @@ -30,13 +30,10 @@ type ClusterContainerChildrenProps = {
};

export type ClusterContainerProps = {
[Key in CampusNames as readonly Key]: {
campus: Key;
// TODO Found a way to make this type more specific than `string`?
cluster: string;
children: (props: ClusterContainerChildrenProps) => JSX.Element;
};
}[CampusNames];
campus: ICampus;
cluster: ICluster;
children: (props: ClusterContainerChildrenProps) => JSX.Element;
};

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

Expand Down
2 changes: 1 addition & 1 deletion web/ui/src/components/Search/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export const Search: SearchComponent = ({
<span
className={
loader
? 'absolute right-0 bg-white dark:bg-slate-900 z-10'
? 'absolute right-2 bg-white dark:bg-slate-950 z-10'
: 'hidden'
}
>
Expand Down
65 changes: 36 additions & 29 deletions web/ui/src/containers/clusters/ClusterSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,47 +3,50 @@ import useSidebar, { Menu, MenuCategory, MenuItem } from '@components/Sidebar';
import { useMe } from '@ctx/currentUser';
import { useClusterSidebarDataQuery } from '@graphql.d';
import { isFirstLoading } from '@lib/apollo';
import Campuses, { CampusNames } from '@lib/clustersMap';
import Campuses, {
CampusIdentifier,
ICampus,
ICluster,
} from '@lib/clustersMap';
import '@lib/prototypes/string';
import { clusterURL } from '@lib/searchEngine';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React from 'react';
import '@lib/prototypes/string';

/**
* ClusterSidebar is the sidebar for the cluster page. It contains the cluster
* menu statically defined. Used accross all cluster pages.
* @param {string} activeCampusName - The campus used to match the current campus
* @param {string} activeClusterIdentifier - The cluster used to match the current cluster
* @param {string} activeCampus - The campus used to match the current campus
* @param {string} activeCluster - The cluster used to match the current cluster
* @returns {JSX.Element} The sub sidebar component
*/
export const ClusterSidebar = ({
activeCampusName,
activeClusterIdentifier,
activeCampus,
activeCluster,
}: {
activeCampusName: CampusNames;
activeClusterIdentifier: string;
activeCampus: ICampus;
activeCluster: ICluster;
}) => {
const { Sidebar } = useSidebar();
const router = useRouter();
const campusKeys = Object.keys(Campuses) as Array<CampusNames>;
const currentCampusData = Campuses[activeCampusName];
const campusKeys = Object.keys(Campuses) as Array<CampusIdentifier>;
const { me } = useMe();
const { data: { locationsStatsByPrefixes = [] } = {}, networkStatus } =
useClusterSidebarDataQuery({
variables: {
campusName: activeCampusName,
clusterPrefixes: currentCampusData
.clusters()
.map((c) => c.identifier()),
campusName: activeCampus.name(),
clusterPrefixes: activeCampus.clusters().map((c) => c.identifier()),
},
});
const myCampusName = me?.currentCampus?.name?.toLowerCase() || '';
const myCampusidentifier =
me?.currentCampus?.name?.removeAccents().toCamelCase() || '';
const freePlacesPerCluster: { [key: string]: number } =
locationsStatsByPrefixes
.map((l) => {
const totalWorkspaces =
currentCampusData.cluster(l.prefix)?.totalWorkspaces() || 0;
activeCampus.cluster(l.prefix)?.totalWorkspaces() || 0;
return [l.prefix, totalWorkspaces - l.occupiedWorkspace];
})
.reduce(
Expand Down Expand Up @@ -95,38 +98,42 @@ export const ClusterSidebar = ({
.sort((a, b) => {
// Sort the campus list in alphabetical order and put the current
// campus at the top.
return a?.equalsIgnoreCase(myCampusName)
return a?.equalsIgnoreCase(myCampusidentifier)
? -1
: b?.equalsIgnoreCase(myCampusName)
: b?.equalsIgnoreCase(myCampusidentifier)
? 1
: a.localeCompare(b);
})
.map((campusName) => {
const campusData = Campuses[campusName];
const isMyCampus = campusName?.equalsIgnoreCase(myCampusName);
const activeCampus = activeCampusName == campusName;
.map((campusIdentifier) => {
const campusData = Campuses[campusIdentifier];
const isMyCampus =
campusIdentifier?.equalsIgnoreCase(myCampusidentifier);
const isActiveCampus =
activeCampus.identifier() == campusData.identifier();

return (
<MenuCategory
key={`sidebar-campus-${campusName}`}
key={`sidebar-campus-${campusData.identifier()}`}
emoji={campusData.emoji()}
name={campusName}
name={campusData.name()}
text={isMyCampus ? 'Your campus' : undefined}
isCollapsable={!isMyCampus}
collapsed={!activeCampus}
collapsed={!isActiveCampus && !isMyCampus}
>
{campusData.clusters().map((cluster) => {
const clusterIdentifier = cluster.identifier();
return (
<Link
href={`/clusters/${campusName}/${clusterIdentifier}`}
href={`/clusters/${campusData
.identifier()
.toSafeLink()}/${clusterIdentifier}`}
passHref={true}
key={`sidebar-clusters-${campusName}-${clusterIdentifier}`}
key={`sidebar-clusters-${campusData.identifier()}-${clusterIdentifier}`}
>
<MenuItem
active={
activeCampus &&
clusterIdentifier == activeClusterIdentifier
isActiveCampus &&
clusterIdentifier == activeCluster.identifier()
}
name={
cluster.hasName()
Expand All @@ -140,7 +147,7 @@ export const ClusterSidebar = ({
: null
}
rightChildren={
activeCampus ? (
isActiveCampus ? (
<>
<span className="pr-1 text-xs">
{freePlacesPerCluster[clusterIdentifier]}
Expand Down
14 changes: 11 additions & 3 deletions web/ui/src/lib/clustersMap/campus.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { CampusNames, ICampus, ICluster } from './types';

import Campuses from '.';
import { ICampus, ICluster } from './types';
import { CampusIdentifier } from './types.generated';
import '@lib/prototypes/string';
/**
* Campus class represents a campus in the cluster map. It contains the
* campus name, emoji, extractor function, and the list of clusters.
Expand All @@ -9,10 +11,16 @@ export class Campus implements ICampus {
throw new Error('Method not implemented.');
}

name(): CampusNames {
name(): string {
throw new Error('Method not implemented.');
}

identifier(): CampusIdentifier {
return Object.keys(Campuses).find(
(key) => Campuses[key as CampusIdentifier] === this,
) as CampusIdentifier;
}

extractorRegexp(): RegExp {
throw new Error('Method not implemented.');
}
Expand Down
4 changes: 2 additions & 2 deletions web/ui/src/lib/clustersMap/campus/helsinki.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Campus } from '../campus';
import { Cluster } from '../cluster';
import { CampusNames, ICampus } from '../types';
import { ICampus } from '../types';

//
export class Helsinki extends Campus implements ICampus {
emoji = (): string => '🇫🇮';

name = (): CampusNames => 'helsinki';
name = (): string => 'Helsinki';

extractorRegexp = (): RegExp =>
/(?<clusterWithLetter>c(?<cluster>\d+))(?<rowWithLetter>r(?<row>\d+))(?<workspaceWithLetter>p(?<workspace>\d+))/i;
Expand Down
4 changes: 2 additions & 2 deletions web/ui/src/lib/clustersMap/campus/lausanne.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Campus } from '../campus';
import { Cluster } from '../cluster';
import { CampusNames, ICampus } from '../types';
import { ICampus } from '../types';

//
export class Lausanne extends Campus implements ICampus {
emoji = (): string => '🇨🇭';

name = (): CampusNames => 'lausanne';
name = (): string => 'Lausanne';

extractorRegexp = (): RegExp =>
/(?<clusterWithLetter>c(?<cluster>\d+))(?<rowWithLetter>r(?<row>\d+))(?<workspaceWithLetter>s(?<workspace>\d+))/i;
Expand Down
4 changes: 2 additions & 2 deletions web/ui/src/lib/clustersMap/campus/madrid.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Campus } from '../campus';
import { Cluster } from '../cluster';
import { CampusNames, ICampus } from '../types';
import { ICampus } from '../types';

//
export class Madrid extends Campus implements ICampus {
emoji = (): string => '🇪🇸';

name = (): CampusNames => 'madrid';
name = (): string => 'Madrid';

extractorRegexp = (): RegExp =>
/(?<clusterWithLetter>c(?<cluster>\d+))(?<rowWithLetter>r(?<row>\d+))(?<workspaceWithLetter>s(?<workspace>\d+))/i;
Expand Down
4 changes: 2 additions & 2 deletions web/ui/src/lib/clustersMap/campus/malaga.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Campus } from '../campus';
import { Cluster } from '../cluster';
import { CampusNames, ICampus } from '../types';
import { ICampus } from '../types';

//
export class Malaga extends Campus implements ICampus {
emoji = (): string => '🇪🇸';

name = (): CampusNames => 'malaga';
name = (): string => 'Malaga';

extractorRegexp = (): RegExp =>
/(?<clusterWithLetter>c(?<cluster>\d+))(?<rowWithLetter>r(?<row>\d+))(?<workspaceWithLetter>p(?<workspace>\d+))/i;
Expand Down
Loading

0 comments on commit d00ba9c

Please sign in to comment.