diff --git a/app/Http/Controllers/ViewProjectsController.php b/app/Http/Controllers/ViewProjectsController.php index d5660820b6..3423c8a187 100644 --- a/app/Http/Controllers/ViewProjectsController.php +++ b/app/Http/Controllers/ViewProjectsController.php @@ -6,21 +6,12 @@ use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Gate; use Illuminate\View\View; final class ViewProjectsController extends AbstractController { - public function viewAllProjects(): View|RedirectResponse - { - return $this->viewProjects(true); - } - - public function viewActiveProjects(): View|RedirectResponse - { - return $this->viewProjects(); - } - - private function viewProjects(bool $all = false): View|RedirectResponse + public function viewProjects(): View|RedirectResponse { $num_public_projects = (int) DB::select(' SELECT COUNT(*) AS c FROM project WHERE public=? @@ -31,8 +22,8 @@ private function viewProjects(bool $all = false): View|RedirectResponse return $this->redirectToLogin(); } - return $this->vue('all-projects', 'Projects', [ - 'show-all' => $all, - ], false); + return $this->vue('projects-page', 'Projects', [ + 'can-create-projects' => Gate::allows('create', Project::class), + ]); } } diff --git a/app/Models/Project.php b/app/Models/Project.php index f982a70540..566bfffb85 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -46,6 +47,7 @@ * @property bool $viewsubprojectslink * @property ?string $ldapfilter * @property ?string $banner + * @property ?string $logoUrl * * @method Builder forUser() * @method Builder administeredByUser() @@ -119,6 +121,22 @@ class Project extends Model public const ACCESS_PUBLIC = 1; public const ACCESS_PROTECTED = 2; + /** + * @return Attribute + */ + protected function logoUrl(): Attribute + { + return Attribute::make( + get: function (mixed $value, array $attributes): ?string { + if ((int) $attributes['imageid'] === 0) { + return null; + } + + return url('/image/' . $attributes['imageid']); + }, + ); + } + /** * Get the users who have been added to this project. Note that this selects users with all roles. * diff --git a/app/cdash/tests/CMakeLists.txt b/app/cdash/tests/CMakeLists.txt index 7e18b6fd60..4db716b11d 100644 --- a/app/cdash/tests/CMakeLists.txt +++ b/app/cdash/tests/CMakeLists.txt @@ -260,7 +260,10 @@ add_browser_test(/Browser/Pages/ProjectSitesPageTest) add_browser_test(/Browser/Pages/ProjectMembersPageTest) -add_browser_test(/Browser/Pages/ProjectPageTest) +add_browser_test(/Browser/Pages/ProjectBuildsPageTest) + +add_browser_test(/Browser/Pages/ProjectsPageTest) +set_property(TEST /Browser/Pages/ProjectsPageTest APPEND PROPERTY RUN_SERIAL TRUE) add_feature_test(/Feature/PurgeUnusedProjectsCommand) set_property(TEST /Feature/PurgeUnusedProjectsCommand APPEND PROPERTY RUN_SERIAL TRUE) diff --git a/graphql/schema.graphql b/graphql/schema.graphql index a3c48cf6a4..0e5a6fe7fc 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -160,6 +160,9 @@ type Project { "A LDAP group users must be a member of to access the project." ldapFilter: String @rename(attribute: "ldapfilter") + "A full URL string pointing to the location of the project's logo." + logoUrl: String + builds( filters: _ @filter ): [Build!]! @hasMany(type: CONNECTION) @orderBy(column: "id") diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e4e337a465..8eca736f97 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -5835,6 +5835,18 @@ parameters: count: 1 path: app/Models/CoverageView.php + - + rawMessage: Binary operation "." between '/image/' and mixed results in an error. + identifier: binaryOp.invalid + count: 1 + path: app/Models/Project.php + + - + rawMessage: Cannot cast mixed to int. + identifier: cast.int + count: 1 + path: app/Models/Project.php + - rawMessage: Binary operation "." between non-falsy-string and mixed results in an error. identifier: binaryOp.invalid diff --git a/resources/js/vue/app.js b/resources/js/vue/app.js index 78fc3a4328..0ec1f69312 100755 --- a/resources/js/vue/app.js +++ b/resources/js/vue/app.js @@ -20,7 +20,7 @@ const TestDetails = Vue.defineAsyncComponent(() => import('./components/TestDeta const HeaderNav = Vue.defineAsyncComponent(() => import('./components/page-header/HeaderNav.vue')); const ViewDynamicAnalysis = Vue.defineAsyncComponent(() => import('./components/ViewDynamicAnalysis.vue')); const ViewDynamicAnalysisFile = Vue.defineAsyncComponent(() => import('./components/ViewDynamicAnalysisFile.vue')); -const AllProjects = Vue.defineAsyncComponent(() => import('./components/AllProjects.vue')); +const ProjectsPage = Vue.defineAsyncComponent(() => import('./components/ProjectsPage.vue')); const SubProjectDependencies = Vue.defineAsyncComponent(() => import('./components/SubProjectDependencies.vue')); const BuildTestsPage = Vue.defineAsyncComponent(() => import('./components/BuildTestsPage.vue')); const ProjectSitesPage = Vue.defineAsyncComponent(() => import('./components/ProjectSitesPage.vue')); @@ -47,7 +47,7 @@ const cdash_components = { HeaderNav, ViewDynamicAnalysis, ViewDynamicAnalysisFile, - AllProjects, + ProjectsPage, SubProjectDependencies, BuildTestsPage, ProjectSitesPage, diff --git a/resources/js/vue/components/AllProjects.vue b/resources/js/vue/components/AllProjects.vue deleted file mode 100644 index 967793d577..0000000000 --- a/resources/js/vue/components/AllProjects.vue +++ /dev/null @@ -1,189 +0,0 @@ - - - diff --git a/resources/js/vue/components/ProjectsPage.vue b/resources/js/vue/components/ProjectsPage.vue new file mode 100644 index 0000000000..b4404d09cc --- /dev/null +++ b/resources/js/vue/components/ProjectsPage.vue @@ -0,0 +1,214 @@ + + + diff --git a/resources/js/vue/components/shared/ProjectLogo.vue b/resources/js/vue/components/shared/ProjectLogo.vue new file mode 100644 index 0000000000..22d9ac1cc0 --- /dev/null +++ b/resources/js/vue/components/shared/ProjectLogo.vue @@ -0,0 +1,58 @@ + + + diff --git a/routes/web.php b/routes/web.php index bbc0e3f5ce..5c6b93dfc2 100755 --- a/routes/web.php +++ b/routes/web.php @@ -209,9 +209,9 @@ // TODO: (williamjallen) This should be in the auth section, but needs to be here until we get rid of Protractor.. Route::get('/manageOverview.php', 'ProjectOverviewController@manageOverview'); -Route::match(['get', 'post'], '/projects', 'ViewProjectsController@viewActiveProjects'); +Route::match(['get', 'post'], '/projects', 'ViewProjectsController@viewProjects'); Route::permanentRedirect('/viewProjects.php', url('/projects')); -Route::match(['get', 'post'], '/projects/all', 'ViewProjectsController@viewAllProjects'); +Route::permanentRedirect('/projects/all', url('/projects')); Route::get('/viewTest.php', 'ViewTestController@viewTest'); diff --git a/tests/Browser/Pages/ProjectPageTest.php b/tests/Browser/Pages/ProjectBuildsPageTest.php similarity index 96% rename from tests/Browser/Pages/ProjectPageTest.php rename to tests/Browser/Pages/ProjectBuildsPageTest.php index 967367c77e..adc7e901f5 100644 --- a/tests/Browser/Pages/ProjectPageTest.php +++ b/tests/Browser/Pages/ProjectBuildsPageTest.php @@ -8,7 +8,7 @@ use Tests\BrowserTestCase; use Tests\Traits\CreatesProjects; -class ProjectPageTest extends BrowserTestCase +class ProjectBuildsPageTest extends BrowserTestCase { use CreatesProjects; diff --git a/tests/Browser/Pages/ProjectsPageTest.php b/tests/Browser/Pages/ProjectsPageTest.php new file mode 100644 index 0000000000..228918d43f --- /dev/null +++ b/tests/Browser/Pages/ProjectsPageTest.php @@ -0,0 +1,236 @@ + + */ + private array $users = []; + + /** + * @var array + */ + private array $projects = []; + + private Site $site; + + public function setUp(): void + { + parent::setUp(); + + $this->site = $this->makeSite(); + $this->updateSiteInfoIfChanged($this->site, new SiteInformation([])); + } + + public function tearDown(): void + { + foreach ($this->users as $user) { + $user->delete(); + } + $this->users = []; + + foreach ($this->projects as $project) { + $project->delete(); + } + $this->projects = []; + + $this->site->delete(); + + parent::tearDown(); + } + + public function testCreateProjectButtonOnlyVisibleToAdminsByDefault(): void + { + $this->users['admin'] = $this->makeAdminUser(); + $this->users['normal'] = $this->makeNormalUser(); + + $this->browse(function (Browser $browser): void { + $browser->loginAs($this->users['admin']) + ->visit('/projects') + ->whenAvailable('@projects-page', function (Browser $browser): void { + $browser->assertVisible('@create-project-button'); + }); + $browser->loginAs($this->users['normal']) + ->visit('/projects') + ->whenAvailable('@projects-page', function (Browser $browser): void { + $browser->assertMissing('@create-project-button'); + }); + + $browser + ->visit('/projects') + ->whenAvailable('@projects-page', function (Browser $browser): void { + $browser->assertMissing('@create-project-button'); + }); + }); + } + + public function testShowsMessageWhenNoProjects(): void + { + $this->users['admin'] = $this->makeAdminUser(); + + $this->browse(function (Browser $browser): void { + $browser->loginAs($this->users['admin']) + ->visit('/projects') + ->whenAvailable('@projects-page', function (Browser $browser): void { + $browser->click('@all-tab') + ->waitFor('@no-projects-message') + ->assertVisible('@no-projects-message') + ->assertMissing('@projects-table') + ; + }); + + $this->projects['project1'] = $this->makePublicProject(); + + $browser->loginAs($this->users['admin']) + ->visit('/projects') + ->whenAvailable('@projects-page', function (Browser $browser): void { + $browser->click('@all-tab') + ->waitFor('@projects-table') + ->assertMissing('@no-projects-message') + ->assertSeeIn('@projects-table', $this->projects['project1']->name) + ; + }); + }); + } + + public function testShowsMessageWhenNoActiveProjects(): void + { + $this->projects['project1'] = $this->makePublicProject(); + $build = $this->projects['project1']->builds()->create([ + 'siteid' => $this->site->id, + 'name' => Str::uuid()->toString(), + 'uuid' => Str::uuid()->toString(), + 'submittime' => Carbon::now()->subDays(2), + ]); + + $this->browse(function (Browser $browser) use ($build): void { + $browser->visit('/projects') + ->whenAvailable('@projects-page', function (Browser $browser): void { + $browser->click('@active-tab') + ->waitFor('@no-active-projects-message') + ->assertVisible('@no-active-projects-message') + ->assertMissing('@projects-table') + ; + }); + + $build->submittime = Carbon::now(); + $build->save(); + + $browser->visit('/projects') + ->whenAvailable('@projects-page', function (Browser $browser): void { + $browser->click('@active-tab') + ->waitFor('@projects-table') + ->assertMissing('@no-active-projects-message') + ->assertSeeIn('@projects-table', $this->projects['project1']->name) + ; + }); + }); + } + + public function testSortsProjectsByNumberOfBuildsInLastDay(): void + { + $this->projects['project1'] = $this->makePublicProject(); + $this->projects['project1']->builds()->create([ + 'siteid' => $this->site->id, + 'name' => Str::uuid()->toString(), + 'uuid' => Str::uuid()->toString(), + 'submittime' => Carbon::now()->subWeek(), + ]); + $this->projects['project1']->builds()->create([ + 'siteid' => $this->site->id, + 'name' => Str::uuid()->toString(), + 'uuid' => Str::uuid()->toString(), + 'submittime' => Carbon::now(), + ]); + + $this->projects['project2'] = $this->makePublicProject(); + $this->projects['project2']->builds()->create([ + 'siteid' => $this->site->id, + 'name' => Str::uuid()->toString(), + 'uuid' => Str::uuid()->toString(), + 'submittime' => Carbon::now(), + ]); + $this->projects['project2']->builds()->create([ + 'siteid' => $this->site->id, + 'name' => Str::uuid()->toString(), + 'uuid' => Str::uuid()->toString(), + 'submittime' => Carbon::now()->subHour(), + ]); + $this->projects['project2']->builds()->create([ + 'siteid' => $this->site->id, + 'name' => Str::uuid()->toString(), + 'uuid' => Str::uuid()->toString(), + 'submittime' => Carbon::now()->subHours(2), + ]); + + $this->projects['project3'] = $this->makePublicProject(); + $this->projects['project3']->builds()->create([ + 'siteid' => $this->site->id, + 'name' => Str::uuid()->toString(), + 'uuid' => Str::uuid()->toString(), + 'submittime' => Carbon::now(), + ]); + $this->projects['project3']->builds()->create([ + 'siteid' => $this->site->id, + 'name' => Str::uuid()->toString(), + 'uuid' => Str::uuid()->toString(), + 'submittime' => Carbon::now()->subHour(), + ]); + + $this->browse(function (Browser $browser): void { + $browser->visit('/projects') + ->whenAvailable('@projects-page', function (Browser $browser): void { + $browser->click('@all-tab') + ->waitFor('@projects-table') + ; + + self::assertSame($this->projects['project2']->name, $browser->elements('@project-name')[0]->getText()); + self::assertSame($this->projects['project3']->name, $browser->elements('@project-name')[1]->getText()); + self::assertSame($this->projects['project1']->name, $browser->elements('@project-name')[2]->getText()); + }); + }); + } + + public function testShowsDateOfLastSubmission(): void + { + $this->projects['project1'] = $this->makePublicProject(); + $this->projects['project1']->builds()->create([ + 'siteid' => $this->site->id, + 'name' => Str::uuid()->toString(), + 'uuid' => Str::uuid()->toString(), + 'submittime' => Carbon::now()->subWeek(), + ]); + + $this->browse(function (Browser $browser): void { + $browser->visit('/projects') + ->whenAvailable('@projects-page', function (Browser $browser): void { + $browser->click('@all-tab') + ->waitFor('@projects-table') + ->assertSeeIn('@projects-table', '7 days ago') + ; + }); + }); + } +}