Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/styles/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ $enable-negative-margins: true;
@import '../../vendor/twbs/bootstrap/scss/buttons';
@import '../../vendor/twbs/bootstrap/scss/button-group';
@import '../../vendor/twbs/bootstrap/scss/breadcrumb';
@import '../../vendor/twbs/bootstrap/scss/card';
@import '../../vendor/twbs/bootstrap/scss/close';
@import '../../vendor/twbs/bootstrap/scss/containers';
@import '../../vendor/twbs/bootstrap/scss/dropdown';
Expand Down
42 changes: 42 additions & 0 deletions assets/styles/modules/_company.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Company overview / vacancy cards.
//
// A flush list-group inside a card draws its dividers with `--bs-list-group-border-color` (a solid colour), which
// reads darker and heavier than the card's own translucent border. Align the two so the dividers sit flush with the
// card edge instead of standing out.
.company-card {
.list-group {
--#{$prefix}list-group-border-color: var(--#{$prefix}card-border-color);
}
}

// Monogram logo placeholder (until real logo serving exists): a centred initial on a muted square. `-sm` is the small
// inline mark used on a vacancy card next to the title; the detail pages use a full-width banner variant inline.
.career-logo {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: var(--#{$prefix}secondary-color);
background-color: var(--#{$prefix}secondary-bg);
}

.career-logo-sm {
flex: none;
width: 2.75rem;
height: 2.75rem;
font-size: 1.1rem;
border-radius: var(--#{$prefix}border-radius);
}

// Clamp the (rendered Markdown) description teaser on a vacancy card, mirroring `.activity-description-content`.
.vacancy-card-excerpt {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;

> :last-child {
margin-bottom: 0;
}
}
4 changes: 2 additions & 2 deletions config/reference.php
Original file line number Diff line number Diff line change
Expand Up @@ -1815,8 +1815,8 @@
* }
* @psalm-type MercureConfig = array{
* hubs?: array<string, array{ // Default: []
* url?: scalar|Param|null, // URL of the hub's publish endpoint
* public_url?: scalar|Param|null, // URL of the hub's public endpoint // Default: null
* url?: scalar|Param|null, // URL of the hub's publish endpoint // Default: null
* public_url?: scalar|Param|null, // URL of the hub's public endpoint
* jwt?: string|array{ // JSON Web Token configuration.
* value?: scalar|Param|null, // JSON Web Token to use to publish to this hub.
* provider?: scalar|Param|null, // The ID of a service to call to provide the JSON Web Token.
Expand Down
109 changes: 109 additions & 0 deletions migrations/Version20260710070912.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* phpcs:disable Generic.Files.LineLength.TooLong
* phpcs:disable SlevomatCodingStandard.Functions.RequireMultiLineCall.RequiredMultiLineCall
*/
final class Version20260710070912 extends AbstractMigration
{
public function getDescription(): string
{
return 'Replace the VacancyCategory entity with a hard-coded VacancyCategories enum; GH-2068.';
}

public function up(Schema $schema): void
{
// Store the category as its enum value (a plain string) on the revision, instead of a foreign key.
$this->addSql('ALTER TABLE VacancyRevision ADD category VARCHAR(255) DEFAULT NULL');

// Best-effort backfill from the old free-form categories: map by the (lowercased) English slug where it lines
// up with one of the new fixed categories, otherwise fall back to `jobs`.
$this->addSql(<<<'SQL'
UPDATE VacancyRevision vr
INNER JOIN VacancyCategory vc ON vr.category_id = vc.id
INNER JOIN CareerLocalisedText slug ON slug.id = vc.slug_id
SET vr.category = CASE LOWER(slug.valueEN)
WHEN 'jobs' THEN 'jobs'
WHEN 'internships' THEN 'internships'
WHEN 'traineeships' THEN 'traineeships'
WHEN 'student-jobs' THEN 'student-jobs'
WHEN 'thesis-projects' THEN 'thesis-projects'
ELSE 'jobs'
END
SQL);
$this->addSql('UPDATE VacancyRevision SET category = \'jobs\' WHERE category IS NULL');
$this->addSql('ALTER TABLE VacancyRevision CHANGE category category VARCHAR(255) NOT NULL');

// Drop the now-unused foreign key, index and column.
$this->addSql('ALTER TABLE VacancyRevision DROP FOREIGN KEY FK_FFE914BF12469DE2');
$this->addSql('DROP INDEX IDX_FFE914BF12469DE2 ON VacancyRevision');
$this->addSql('ALTER TABLE VacancyRevision DROP category_id');

// Remove the category table and the localised texts it owned. The localised texts are orphan-removing in the
// ORM, but that is not a database cascade, so clean them up here (FK checks off to sidestep ordering).
$this->addSql('SET FOREIGN_KEY_CHECKS = 0');
$this->addSql(<<<'SQL'
DELETE clt FROM CareerLocalisedText clt
INNER JOIN VacancyCategory vc ON clt.id IN (vc.name_id, vc.pluralName_id, vc.slug_id)
SQL);
$this->addSql('DROP TABLE VacancyCategory');
$this->addSql('SET FOREIGN_KEY_CHECKS = 1');
}

public function down(Schema $schema): void
{
// Recreate the category table and its localised-text foreign keys.
$this->addSql('CREATE TABLE VacancyCategory (id INT AUTO_INCREMENT NOT NULL, name_id INT NOT NULL, slug_id INT NOT NULL, hidden TINYINT(1) NOT NULL, pluralName_id INT NOT NULL, UNIQUE INDEX UNIQ_94C618B271179CD6 (name_id), UNIQUE INDEX UNIQ_94C618B2A8BEABC (pluralName_id), UNIQUE INDEX UNIQ_94C618B2311966CE (slug_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE VacancyCategory ADD CONSTRAINT FK_9701A2A671179CD6 FOREIGN KEY (name_id) REFERENCES CareerLocalisedText (id)');
$this->addSql('ALTER TABLE VacancyCategory ADD CONSTRAINT FK_9701A2A6A8BEABC FOREIGN KEY (pluralName_id) REFERENCES CareerLocalisedText (id)');
$this->addSql('ALTER TABLE VacancyCategory ADD CONSTRAINT FK_9701A2A6311966CE FOREIGN KEY (slug_id) REFERENCES CareerLocalisedText (id)');

// Add the foreign key column back (nullable for now, so we can backfill).
$this->addSql('ALTER TABLE VacancyRevision ADD category_id INT DEFAULT NULL');

// Reconstitute the fixed categories as rows so existing revisions can point at them again. The original
// free-form names are unrecoverable, so we recreate them from the enum (Dutch labels are best-effort).
$this->reconstituteCategory('jobs', 'Job', 'Baan', 'Jobs', 'Banen');
$this->reconstituteCategory('internships', 'Internship', 'Stage', 'Internships', 'Stages');
$this->reconstituteCategory('traineeships', 'Traineeship', 'Traineeship', 'Traineeships', 'Traineeships');
$this->reconstituteCategory('student-jobs', 'Student job', 'Studentenbaan', 'Student jobs', 'Studentenbanen');
$this->reconstituteCategory('thesis-projects', 'Thesis project', 'Afstudeerproject', 'Thesis projects', 'Afstudeerprojecten');

// Any revision whose category value is unknown falls back to `jobs`.
$this->addSql('UPDATE VacancyRevision vr INNER JOIN VacancyCategory vc ON vc.id = (SELECT vc2.id FROM VacancyCategory vc2 INNER JOIN CareerLocalisedText s ON s.id = vc2.slug_id WHERE s.valueEN = \'jobs\' LIMIT 1) SET vr.category_id = vc.id WHERE vr.category_id IS NULL');

$this->addSql('ALTER TABLE VacancyRevision CHANGE category_id category_id INT NOT NULL');
$this->addSql('ALTER TABLE VacancyRevision ADD CONSTRAINT FK_FFE914BF12469DE2 FOREIGN KEY (category_id) REFERENCES VacancyCategory (id)');
$this->addSql('CREATE INDEX IDX_FFE914BF12469DE2 ON VacancyRevision (category_id)');
$this->addSql('ALTER TABLE VacancyRevision DROP category');
}

/**
* Recreates a single {@see \App\Entity\Career\VacancyCategory} row (with its three localised texts) and repoints
* every revision whose enum value matches $slug back at it. Uses session variables, which survive the implicit
* commits of the surrounding statements on the same connection.
*/
private function reconstituteCategory(
string $slug,
string $nameEn,
string $nameNl,
string $pluralEn,
string $pluralNl,
): void {
$this->addSql('INSERT INTO CareerLocalisedText (valueEN, valueNL) VALUES (:en, :nl)', ['en' => $nameEn, 'nl' => $nameNl]);
$this->addSql('SET @name_id = LAST_INSERT_ID()');
$this->addSql('INSERT INTO CareerLocalisedText (valueEN, valueNL) VALUES (:en, :nl)', ['en' => $pluralEn, 'nl' => $pluralNl]);
$this->addSql('SET @plural_id = LAST_INSERT_ID()');
$this->addSql('INSERT INTO CareerLocalisedText (valueEN, valueNL) VALUES (:slug, :slug)', ['slug' => $slug]);
$this->addSql('SET @slug_id = LAST_INSERT_ID()');
$this->addSql('INSERT INTO VacancyCategory (name_id, pluralName_id, slug_id, hidden) VALUES (@name_id, @plural_id, @slug_id, 0)');
$this->addSql('UPDATE VacancyRevision SET category_id = LAST_INSERT_ID() WHERE category = :slug', ['slug' => $slug]);
}
}
25 changes: 0 additions & 25 deletions src/Controller/Career/AdminVacancyCategoryController.php

This file was deleted.

107 changes: 106 additions & 1 deletion src/Controller/Career/CareerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,127 @@

namespace App\Controller\Career;

use App\Entity\Career\Enums\VacancyCategories;
use App\Repository\Activity\ActivityRepository;
use App\Repository\Career\CompanyRepository;
use App\Repository\Career\VacancyRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

use function shuffle;

#[Route(
path: '/career',
name: 'career/',
)]
class CareerController extends AbstractController
{
/**
* The number of a company's upcoming activities shown on its detail page.
*/
private const int COMPANY_ACTIVITY_LIMIT = 3;

public function __construct(
private readonly CompanyRepository $companyRepository,
private readonly VacancyRepository $vacancyRepository,
private readonly ActivityRepository $activityRepository,
) {
}

/**
* The public career landing page: an overview of all companies that are currently visible.
*/
#[Route(
path: '',
name: 'index',
)]
public function index(): Response
{
return $this->render('career/index.html.twig');
$companies = $this->companyRepository->findAllPublic();

// Randomise the order so no company is structurally favoured.
shuffle($companies);

return $this->render(
'career/index.html.twig',
['companies' => $companies],
);
}

/**
* The public detail page of a single company: its full description, contact details and active vacancies.
*/
#[Route(
path: '/company/{slug}',
name: 'company',
)]
public function company(string $slug): Response
{
$company = $this->companyRepository->findPublicCompanyBySlugName($slug);

if (null === $company) {
throw $this->createNotFoundException();
}

return $this->render(
'career/company.html.twig',
[
'company' => $company,
'activities' => $this->activityRepository->findUpcomingByCompany(
$company,
self::COMPANY_ACTIVITY_LIMIT,
),
],
);
}

/**
* The public vacancies overview. All filtering (category, company, labels, search) is handled by the
* {@see \App\Twig\Components\Career\VacancyOverview} live component, which mirrors its state into the query string;
* a company card links here with `?category=...&company=...` pre-applied.
*/
#[Route(
path: '/vacancies',
name: 'vacancies',
)]
public function vacancies(): Response
{
return $this->render('career/vacancies.html.twig');
}

/**
* The public detail page of a single vacancy: its full description and the outward link to apply. Identified by the
* owning company, its category and its slug (unique within that pair).
*/
#[Route(
path: '/company/{companySlug}/{category}/{vacancySlug}',
name: 'vacancy',
)]
public function vacancy(
string $companySlug,
string $category,
string $vacancySlug,
): Response {
$categoryEnum = VacancyCategories::tryFrom($category);

if (null === $categoryEnum) {
throw $this->createNotFoundException();
}

$vacancy = $this->vacancyRepository->findPublicVacancy(
$companySlug,
$categoryEnum,
$vacancySlug,
);

if (null === $vacancy) {
throw $this->createNotFoundException();
}

return $this->render(
'career/vacancy.html.twig',
['vacancy' => $vacancy],
);
}
}
Loading