From c7c2a19c1e22e04f0a14717b732f18acc7770e55 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Micha=C3=ABl=20Minelli?= <git@minelli.swiss>
Date: Tue, 25 Mar 2025 14:28:56 +0100
Subject: [PATCH] ExerciseSearchCommand => Refactor and fix the command

---
 .../subcommands/ExerciseListCommand.ts        | 235 -------------
 .../subcommands/ExerciseSearchCommand.ts      | 312 ++++++++++++++++++
 NodeApp/src/sharedByClients                   |   2 +-
 3 files changed, 313 insertions(+), 236 deletions(-)
 delete mode 100644 NodeApp/src/commander/exercise/subcommands/ExerciseListCommand.ts
 create mode 100644 NodeApp/src/commander/exercise/subcommands/ExerciseSearchCommand.ts

diff --git a/NodeApp/src/commander/exercise/subcommands/ExerciseListCommand.ts b/NodeApp/src/commander/exercise/subcommands/ExerciseListCommand.ts
deleted file mode 100644
index 319e3bc..0000000
--- a/NodeApp/src/commander/exercise/subcommands/ExerciseListCommand.ts
+++ /dev/null
@@ -1,235 +0,0 @@
-// ExerciseListCommand.ts
-import CommanderCommand from '../../CommanderCommand';
-import chalk from 'chalk';
-import ora from 'ora';
-import DojoBackendManager from '../../../managers/DojoBackendManager';
-import AccessesHelper from '../../../helpers/AccessesHelper';
-import Exercise from '../../../sharedByClients/models/Exercise';
-import inquirer from 'inquirer';
-import Table from 'cli-table3';
-
-import Fuse from 'fuse.js';
-import User from '../../../sharedByClients/models/User';
-
-class ExerciseListCommand extends CommanderCommand {
-    protected commandName: string = 'list';
-
-    protected defineCommand(): void {
-        this.command
-            .description('list your exercises')
-            .action(this.commandAction.bind(this));
-    }
-
-    protected async commandAction(): Promise<void> {
-        console.log(chalk.cyan('Please wait while we retrieve your exercises...'));
-
-        // Check access
-        if (!await AccessesHelper.checkStudent()) {
-            return;
-        }
-
-        // Fetch user's exercises
-        const userExercises: Exercise[] | undefined = await DojoBackendManager.getUserExercises();
-
-        if (!userExercises || userExercises.length === 0) {
-            ora().info('You have no exercises yet.');
-            return;
-        }
-
-        // Display the list of exercises
-        this.displayExerciseList(userExercises);
-
-        // Ask the user for further actions
-        await this.askUserForActions(userExercises);
-    }
-
-    private async askUserForActions(exercises: Exercise[]): Promise<void> {
-        const { action } = await inquirer.prompt([
-            {
-                type: 'list',
-                name: 'action',
-                message: 'Que souhaitez-vous faire ?',
-                choices: [
-                    { name: 'Voir les détails d\'exercice', value: 'details'},
-                    { name: 'Filter les exercises', value: 'filter' },
-                    { name: 'Exit', value: 'exit' },
-                ],
-            },
-        ]);
-
-        if (action === 'details') {
-            await this.selectExerciseForDetails(exercises);
-        } else if (action === 'filter') {
-            await this.filterExercises(exercises);
-        } else {
-            ora().info('No further actions selected.');
-        }
-    }
-
-    private async selectExerciseForDetails(exercises: Exercise[]): Promise<void> {
-        const { selectedExercise } = await inquirer.prompt([{
-            type: 'list',
-            name: 'selectedExercise',
-            message: 'Selectionner un exercice :',
-            choices: [
-                ...exercises.map(exercise => ({
-                    name: exercise.name,
-                    value: exercise.id,
-                })),
-                { name: 'Exit', value: 'exit' },
-            ],
-        }]);
-
-        if (selectedExercise === 'exit') {
-            ora().info('Pas de détails requis: détails dispo  avec la commande `dojo exercise info <id>`.');
-            return;
-        }
-
-        const selected = exercises.find(ex => ex.id === selectedExercise);
-        if (selected) {
-            await this.displayExerciseDetails(selected);
-        } else {
-            ora().info('Invalid selection. No exercise details to show.');
-        }
-    }
-
-    private async filterExercises(exercises: Exercise[]): Promise<void> {
-        const { filterType } = await inquirer.prompt([
-            {
-                type: 'list',
-                name: 'filterType',
-                message: 'Comment souhaitez-vous filtrer les exercices ?',
-                choices: [
-                    { name: 'Par saisie texte', value: 'fuzzy' },
-                    //{ name: 'Par professeurs', value: 'professor' },
-                    { name: 'Exit', value: 'exit' },
-                ],
-            },
-        ]);
-
-        if (filterType === 'fuzzy') {
-            await this.fuzzySearchExercises(exercises);
-        } else if (filterType === 'professor') {
-            await this.filterByProfessor(exercises);
-        } else {
-            ora().info('No filtering selected.');
-        }
-    }
-
-    private async fuzzySearchExercises(exercises: Exercise[]): Promise<void> {
-        const { searchQuery } = await inquirer.prompt([
-            {
-                type: 'input',
-                name: 'searchQuery',
-                message: 'Entrez le nom de l\'exercice (laisser vide pour la liste complète) :',
-            },
-        ]);
-
-        if (!searchQuery) {
-            this.displayExerciseList(exercises);
-            return;
-        }
-
-        const fuse = new Fuse(exercises, {
-            keys: ['name'],
-            threshold: 0.5,
-            distance: 150,
-        });
-
-        const searchResults = fuse.search(searchQuery).map(result => result.item);
-
-        if (searchResults.length === 0) {
-            ora().info('Aucun exercice trouvé correspondant à votre recherche.');
-            return;
-        }
-
-        if (searchResults.length === 1) {
-            // Display details and members for the single matching exercise
-            const singleExercise = searchResults[0];
-            this.displayExerciseDetails(singleExercise);
-        } else {
-            // Display only exercise names and info about viewing details
-            ora().info(' Plusieurs exercices trouvés correspondant à votre recherche :');
-            const exerciseNames = searchResults.map(exercise => exercise.name);
-            console.log('  ', exerciseNames.join('\n   '));
-
-            ora().info('Les détails sont disponibles avec la commande : `dojo exercise info <id>`.');
-        }
-    }
-
-    private async filterByProfessor(exercises: Exercise[]): Promise<void> {
-
-        const professors: User[] | undefined = await DojoBackendManager.getProfessors();
-
-        if (!professors || professors.length === 0) {
-            ora().info('No professors found.');
-            return;
-        }
-
-        const professorChoices = professors.map(professor => ({
-            name: `${professor.gitlabUsername}`,
-            value: professor // Use the professor object as the value
-        }));
-
-        const { selectedProfessor } = await inquirer.prompt([
-            {
-                type: 'list',
-                name: 'selectedProfessor',
-                message: 'Selectionnez un professeur:',
-                choices: professorChoices
-            }
-        ]);
-
-        console.log(`Selected professor: ${selectedProfessor.gitlabUsername}`);
-        ora().info('Filter by professor is not yet implemented.');
-    }
-
-    private displayExerciseList(exercises: Exercise[]): void {
-        const headers = ['Exercise Name', 'GitLab Link'];
-
-        // Calculate the maximum width for each column
-        const maxWidths = headers.map(header => header.length);
-
-        exercises.forEach(exercise => {
-            maxWidths[0] = Math.max(maxWidths[0], exercise.name.length);
-            maxWidths[1] = Math.max(maxWidths[1], exercise.gitlabLink.length);
-        });
-
-        const table = new Table({
-            head: headers,
-        });
-
-        exercises.forEach((exercise) => {
-            table.push([
-                exercise.name,
-                exercise.gitlabLink,
-            ]);
-        });
-
-        ora().info('Your exercises:');
-        console.log(table.toString());
-    }
-
-    private async displayExerciseDetails(exercise: Exercise): Promise<void> {
-        ora().info(`Detail of Exercise with ID: ${exercise.id}`);
-        console.log(chalk.magenta('  - Exercise Name:'), exercise.name);
-        console.log(chalk.magenta('  - Assignment Name:'), exercise.assignmentName);
-        console.log(chalk.magenta('  - GitLab ID:'), exercise.gitlabId);
-        console.log(chalk.magenta('  - GitLab Link:'), chalk.blue.underline(exercise.gitlabLink));
-        console.log(chalk.magenta('  - GitLab Last Info Date:'), exercise.gitlabLastInfoDate);
-
-        // Fetch exercise members
-        const exerciseMembers = await DojoBackendManager.getExerciseMembers(exercise.id);
-
-        if (exerciseMembers && exerciseMembers.length > 0) {
-            ora().info('Exercise Members:');
-            exerciseMembers.forEach(member => {
-                console.log(chalk.magenta(`  - ${member.id} ${member.name}`));
-            });
-        } else {
-            ora().info('No members found for this exercise.');
-        }
-    }
-}
-
-export default new ExerciseListCommand();
\ No newline at end of file
diff --git a/NodeApp/src/commander/exercise/subcommands/ExerciseSearchCommand.ts b/NodeApp/src/commander/exercise/subcommands/ExerciseSearchCommand.ts
new file mode 100644
index 0000000..39c3cb7
--- /dev/null
+++ b/NodeApp/src/commander/exercise/subcommands/ExerciseSearchCommand.ts
@@ -0,0 +1,312 @@
+// ExerciseListCommand.ts
+import CommanderCommand   from '../../CommanderCommand';
+import ora                from 'ora';
+import DojoBackendManager from '../../../managers/DojoBackendManager';
+import AccessesHelper     from '../../../helpers/AccessesHelper';
+import Exercise           from '../../../sharedByClients/models/Exercise';
+import inquirer           from 'inquirer';
+import Table              from 'cli-table3';
+import Fuse               from 'fuse.js';
+import User               from '../../../sharedByClients/models/User';
+import TextStyle          from '../../../types/TextStyle';
+import ExerciseHelper     from '../../../helpers/Dojo/ExerciseHelper';
+import { Option }         from 'commander';
+import Config             from '../../../config/Config';
+
+
+type CommandOptions = { all: boolean, name: string, teacher: string };
+
+
+class ExerciseListCommand extends CommanderCommand {
+    protected commandName: string = 'list';
+
+    protected teachers: User[] = [];
+
+    protected allExercises: Exercise[] = [];
+    protected filteredExercises: Exercise[] = [];
+
+    protected currentSearchFilter = '';
+
+    protected defineCommand(): void {
+        this.command
+            .description('list your exercises')
+            .addOption(new Option('-a, --all', 'list all exercises').conflicts([ 'name', 'teacher' ]))
+            .addOption(new Option('-n, --name <pattern_to_search>', 'search exercises by name').conflicts([ 'all', 'teacher' ]))
+            .addOption(new Option('-t, --teacher <pattern_to_search>', 'search exercises by teacher').conflicts([ 'all', 'name' ]))
+            .action(this.commandAction.bind(this));
+    }
+
+    private async dataRetrieval(getTeachers: boolean) {
+        console.log(TextStyle.BLOCK('Please wait while we verify and retrieve data...'));
+
+        // Check access
+        await AccessesHelper.checkStudent();
+
+        // Fetch teachers
+        if ( getTeachers ) {
+            const teachersGetSpinner: ora.Ora = ora(`Fetching teachers`).start();
+            try {
+                const teachers: Array<User> | undefined = await DojoBackendManager.getTeachers();
+                if ( teachers ) {
+                    this.teachers = teachers;
+                } else {
+                    throw new Error();
+                }
+            } catch ( error ) {
+                teachersGetSpinner.fail(`Error while fetching teachers.`);
+                throw new Error();
+            }
+            teachersGetSpinner.succeed(`Teachers fetched successfully.`);
+        }
+
+        // Fetch user's exercises
+        const exercisesGetSpinner: ora.Ora = ora(`Checking user's exercises`).start();
+
+        this.allExercises = await DojoBackendManager.getUserExercises() ?? [];
+        this.filteredExercises = this.allExercises;
+
+        if ( this.allExercises.length === 0 ) {
+            exercisesGetSpinner.fail(`You do not have any exercises yet.`);
+            throw new Error();
+        }
+
+        exercisesGetSpinner.succeed(`User's exercises fetched successfully.`);
+    }
+
+    private clear(): void {
+        this.currentSearchFilter = '';
+        this.filteredExercises = this.allExercises;
+    }
+
+    private async displayMenu(): Promise<void> {
+        // eslint-disable-next-line no-constant-condition
+        while ( true ) {
+            console.log('');
+            ora(`${ '='.repeat(25) } Current filter: ${ this.currentSearchFilter == '' ? 'no filter' : this.currentSearchFilter } ${ '='.repeat(25) }`).info();
+
+            const action: string = (await inquirer.prompt([ {
+                type   : 'list',
+                name   : 'action',
+                message: 'What do you want ?',
+                choices: [ {
+                    name : 'Display current filtered exercises list',
+                    value: 'list'
+                }, new inquirer.Separator(), {
+                    name : 'Get details of an exercise',
+                    value: 'details'
+                }, new inquirer.Separator(), {
+                    name : 'Filter by name',
+                    value: 'fuzzy'
+                }, {
+                    name : 'Filter by teacher',
+                    value: 'teacher'
+                }, new inquirer.Separator(), {
+                    name : 'Clear filters',
+                    value: 'clear'
+                }, new inquirer.Separator(), {
+                    name : 'Exit',
+                    value: 'exit'
+                } ]
+            } ])).action;
+
+            switch ( action ) {
+                case 'list':
+                    await this.displayExerciseList();
+                    break;
+                case 'details':
+                    await this.selectExerciseForDetails();
+                    return;
+                case 'fuzzy':
+                    await this.filterByExerciseName();
+                    break;
+                case 'teacher':
+                    await this.filterByTeacherInteractive();
+                    break;
+                case 'clear':
+                    this.clear();
+                    break;
+                case 'exit':
+                    throw new Error();
+                default:
+                    ora().info('Invalid filter type.');
+                    return;
+            }
+        }
+    }
+
+    private async selectExerciseForDetails(): Promise<void> {
+        const { selectedExercise } = await inquirer.prompt([ {
+            type   : 'list',
+            name   : 'selectedExercise',
+            message: 'Please select an exercise :',
+            choices: [ ...this.filteredExercises.map(exercise => ({
+                name : exercise.name,
+                value: exercise.id
+            })), new inquirer.Separator(), {
+                name : 'Cancel',
+                value: 'cancel'
+            } ]
+        } ]);
+
+        if ( selectedExercise === 'cancel' ) {
+            return;
+        }
+
+        const selected = this.filteredExercises.find(ex => ex.id === selectedExercise);
+        if ( selected ) {
+            return ExerciseHelper.displayDetails(selected, true);
+        } else {
+            ora().info('Invalid selection. No exercise details to show.');
+        }
+    }
+
+    private async filterByExerciseName(searchQuery: string | undefined = undefined): Promise<void> {
+        if ( searchQuery === undefined ) {
+            searchQuery = (await inquirer.prompt([ {
+                type   : 'input',
+                name   : 'searchQuery',
+                message: 'Please enter the searched string (leave blank if you want all exercises list):'
+            } ])).searchQuery;
+        }
+
+        this.currentSearchFilter = `[Name] ${ searchQuery }`;
+
+        if ( !searchQuery ) {
+            this.filteredExercises = this.allExercises;
+        } else {
+            const fuse = new Fuse(this.allExercises, {
+                keys     : [ 'name' ],
+                threshold: 0.5,
+                distance : 150
+            });
+
+            this.filteredExercises = fuse.search(searchQuery).map(result => result.item);
+        }
+
+        await this.displayExerciseList();
+    }
+
+    private async filterByTeacher(searchQuery: string): Promise<void> {
+        if ( this.teachers.length === 0 ) {
+            ora().info('No teachers found.');
+            return;
+        }
+
+        this.currentSearchFilter = `[Teacher] ${ searchQuery }`;
+
+        const exercises: Array<Exercise & { teachers: string }> = this.allExercises.map(exercise => ({
+            ...exercise,
+            teachers: (exercise.assignment?.staff ?? []).map(staff => staff.gitlabUsername).join(' ')
+        })) as Array<Exercise & { teachers: string }>;
+
+        const fuse = new Fuse(exercises, {
+            keys     : [ 'teachers' ],
+            threshold: 0.5,
+            distance : 150
+        });
+
+        this.filteredExercises = fuse.search(searchQuery).map(result => result.item);
+
+        await this.displayExerciseList();
+    }
+
+    private async filterByTeacherInteractive(): Promise<void> {
+        if ( this.teachers.length === 0 ) {
+            ora().info('No teachers found.');
+            return;
+        }
+
+        const teacherChoices = this.teachers.map(teacher => ({
+            name : `${ teacher.gitlabUsername }`,
+            value: teacher
+        }));
+
+        teacherChoices.sort((a, b) => a.name.split('.')[1].localeCompare(b.name.split('.')[1]));
+
+        const selectedTeacher: User = (await inquirer.prompt([ {
+            type   : 'list',
+            name   : 'selectedTeacher',
+            message: 'Please select a teacher:',
+            choices: teacherChoices
+        } ])).selectedTeacher;
+
+        this.currentSearchFilter = `[Teacher] ${ selectedTeacher.gitlabUsername }`;
+
+        this.filteredExercises = this.allExercises.filter(exercise => (exercise.assignment?.staff ?? []).find(staff => staff.id === selectedTeacher.id) !== undefined);
+
+        await this.displayExerciseList();
+    }
+
+    private async displayExerciseList(): Promise<void> {
+        ora(`Search results for filter: ${ this.currentSearchFilter == '' ? 'no filter' : this.currentSearchFilter }`).info();
+
+        if ( this.filteredExercises.length === 0 ) {
+            ora().info('No exercises found.');
+            return;
+        }
+
+        this.filteredExercises.forEach(exercise => {
+            console.log(TextStyle.LIST_ITEM_NAME(`➡ ${ exercise.name }`));
+            console.log(`    ${ TextStyle.LIST_SUBITEM_NAME('- Id:') } ${ exercise.id }`);
+            console.log(`    ${ TextStyle.LIST_SUBITEM_NAME('- Gitlab URL:') } ${ exercise.gitlabCreationInfo.web_url }`);
+        });
+    }
+
+    private async displayExerciseTable(): Promise<void> {
+        ora(`Search results for filter: ${ this.currentSearchFilter == '' ? 'no filter' : this.currentSearchFilter }`).info();
+
+        if ( this.filteredExercises.length === 0 ) {
+            ora().info('No exercises found.');
+            return;
+        }
+
+        const headers = [ 'Exercise Name', 'GitLab Link' ];
+
+        // Calculate the maximum width for each column
+        const maxWidths = headers.map(header => header.length);
+
+        this.filteredExercises.forEach(exercise => {
+            maxWidths[0] = Math.max(maxWidths[0], exercise.name.length);
+            maxWidths[1] = Math.max(maxWidths[1], exercise.gitlabLink.length);
+        });
+
+        const table = new Table({
+                                    head: headers
+                                });
+
+        this.filteredExercises.forEach((exercise) => {
+            table.push([ exercise.name, exercise.gitlabLink ]);
+        });
+
+        console.log(table.toString());
+    }
+
+    protected async commandAction(options: CommandOptions): Promise<void> {
+        try {
+            if ( !options.all && !options.name && !options.teacher && !Config.interactiveMode ) {
+                ora().fail('At least one filter or interactive mode is required.');
+                this.command.help();
+                return;
+            }
+
+            await this.dataRetrieval(!(options.all || options.name));
+
+            if ( Config.interactiveMode ) {
+                await this.displayMenu();
+            } else {
+                if ( options.all ) {
+                    await this.displayExerciseList();
+                } else if ( options.name ) {
+                    await this.filterByExerciseName(options.name);
+                } else if ( options.teacher ) {
+                    await this.filterByTeacher(options.teacher);
+                }
+
+                ora().info(`${ TextStyle.TIPS('[Tips]') } If you want to see more details about an exercise, use the command ${ TextStyle.CODE('dojo exercise info <id or url>') }.`);
+            }
+        } catch ( e ) { /* Do nothing */ }
+    }
+}
+
+
+export default new ExerciseListCommand();
\ No newline at end of file
diff --git a/NodeApp/src/sharedByClients b/NodeApp/src/sharedByClients
index 530fe74..81c1c69 160000
--- a/NodeApp/src/sharedByClients
+++ b/NodeApp/src/sharedByClients
@@ -1 +1 @@
-Subproject commit 530fe7459023f7fa11b14a9edf7a99629304cf8b
+Subproject commit 81c1c69cdc9ed0b381c60fe4fd2e4668abe00625
-- 
GitLab