Commit f52f6e97 authored by Emma's avatar Emma 😻

theme inheritance

parent 1dfdc51d
<?php
namespace Raddit\AppBundle\DoctrineMigrations;
use Doctrine\DBAL\Migrations\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
class Version20170918070116 extends AbstractMigration {
/**
* @param Schema $schema
*/
public function up(Schema $schema) {
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE theme_revisions ADD parent_id UUID DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN theme_revisions.parent_id IS \'(DC2Type:uuid)\'');
$this->addSql('ALTER TABLE theme_revisions ADD CONSTRAINT FK_4772F808727ACA70 FOREIGN KEY (parent_id) REFERENCES theme_revisions (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_4772F808727ACA70 ON theme_revisions (parent_id)');
}
/**
* @param Schema $schema
*/
public function down(Schema $schema) {
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.');
$this->addSql('ALTER TABLE theme_revisions DROP CONSTRAINT FK_4772F808727ACA70');
$this->addSql('DROP INDEX IDX_4772F808727ACA70');
$this->addSql('ALTER TABLE theme_revisions DROP parent_id');
}
}
......@@ -47,13 +47,14 @@ class Theme {
private $revisions;
/**
* @param string $name
* @param User $author
* @param null|string $commonCss
* @param null|string $dayCss
* @param null|string $nightCss
* @param bool $appendToDefaultStyle
* @param string $comment
* @param string $name
* @param User $author
* @param null|string $commonCss
* @param null|string $dayCss
* @param null|string $nightCss
* @param bool $appendToDefaultStyle
* @param string|null $comment
* @param ThemeRevision|null $parent
*/
public function __construct(
string $name,
......@@ -62,22 +63,23 @@ class Theme {
$dayCss,
$nightCss,
bool $appendToDefaultStyle,
string $comment
$comment,
ThemeRevision $parent = null
) {
$this->id = Uuid::uuid4();
$this->name = $name;
$this->author = $author;
$this->revisions = new ArrayCollection();
$revision = new ThemeRevision(
new ThemeRevision(
$this,
$commonCss,
$dayCss,
$nightCss,
$appendToDefaultStyle,
$comment
$comment,
$parent
);
$this->revisions = new ArrayCollection([$revision]);
}
public function getId(): Uuid {
......
......@@ -66,6 +66,13 @@ class ThemeRevision {
*/
private $modified;
/**
* @ORM\ManyToOne(targetEntity="ThemeRevision")
*
* @var ThemeRevision
*/
private $parent;
public function __construct(
Theme $theme,
$commonCss,
......@@ -73,12 +80,17 @@ class ThemeRevision {
$nightCss,
bool $appendToDefaultStyle,
$comment,
ThemeRevision $parent = null,
\DateTime $modified = null
) {
if (!$commonCss && !$dayCss && !$nightCss) {
throw new \DomainException('At least one CSS field must be filled');
}
if ($parent->parent->parent->parent ?? false) {
throw new \DomainException('A theme cannot have more than three parents');
}
$this->id = Uuid::uuid4();
$this->theme = $theme;
$this->commonCss = $commonCss;
......@@ -86,7 +98,9 @@ class ThemeRevision {
$this->nightCss = $nightCss;
$this->appendToDefaultStyle = $appendToDefaultStyle;
$this->comment = $comment;
$this->parent = $parent;
$this->modified = $modified ?: new \DateTime('@'.time());
$theme->addRevision($this);
}
public function getId(): Uuid {
......@@ -129,6 +143,40 @@ class ThemeRevision {
return $this->comment;
}
/**
* @return ThemeRevision|null
*/
public function getParent() {
return $this->parent;
}
public function getParentCount(): int {
$count = 0;
while (($parent = ($parent ?? $this)->getParent())) {
$count++;
}
return $count;
}
/**
* Get all parents and self in the correct include order.
*
* @return string[]
*/
public function getHierarchy(): array {
$hierarchy = [];
while (($parent = ($parent ?? $this)->getParent())) {
array_unshift($hierarchy, $parent);
}
$hierarchy[] = $this;
return $hierarchy;
}
public function getModified(): \DateTime {
return $this->modified;
}
......
......@@ -64,19 +64,30 @@ class ThemeData {
*/
public $comment;
/**
* @Assert\Expression("value == null or value.getParentCount() < 3",
* message="That theme cannot be extended.")
*
* @var ThemeRevision|null
*/
public $parent;
public function __construct(User $author) {
// needed for UniqueEntity validator to work
$this->author = $author;
}
public static function createFromTheme(Theme $theme): self {
$revision = $theme->getLatestRevision();
$self = new self($theme->getAuthor());
$self->name = $theme->getName();
$self->commonCss = $theme->getLatestRevision()->getCommonCss();
$self->dayCss = $theme->getLatestRevision()->getDayCss();
$self->nightCss = $theme->getLatestRevision()->getNightCss();
$self->appendToDefaultStyle = $theme->getLatestRevision()->appendToDefaultStyle();
$self->commonCss = $revision->getCommonCss();
$self->dayCss = $revision->getDayCss();
$self->nightCss = $revision->getNightCss();
$self->appendToDefaultStyle = $revision->appendToDefaultStyle();
$self->entityId = $theme->getId();
$self->parent = $revision->getParent();
return $self;
}
......@@ -89,7 +100,8 @@ class ThemeData {
$this->dayCss,
$this->nightCss,
$this->appendToDefaultStyle,
$this->comment
$this->comment,
$this->parent
);
}
......@@ -102,7 +114,8 @@ class ThemeData {
$this->commonCss !== $revision->getCommonCss() ||
$this->dayCss !== $revision->getDayCss() ||
$this->nightCss !== $revision->getNightCss() ||
$this->appendToDefaultStyle !== $revision->appendToDefaultStyle()
$this->appendToDefaultStyle !== $revision->appendToDefaultStyle() ||
$this->parent !== $revision->getParent()
) {
$revision = new ThemeRevision(
$theme,
......@@ -110,7 +123,8 @@ class ThemeData {
$this->dayCss,
$this->nightCss,
$this->appendToDefaultStyle,
$this->comment
$this->comment,
$this->parent
);
$theme->addRevision($revision);
......
......@@ -2,8 +2,14 @@
namespace Raddit\AppBundle\Form;
use Doctrine\ORM\EntityManagerInterface;
use Raddit\AppBundle\Entity\ThemeRevision;
use Raddit\AppBundle\Form\Model\ThemeData;
use Raddit\AppBundle\Repository\ThemeRepository;
use Ramsey\Uuid\Uuid;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
......@@ -11,8 +17,31 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ThemeType extends AbstractType {
/**
* @var ThemeRepository
*/
private $themeRepository;
/**
* @var EntityManagerInterface
*/
private $em;
public function __construct(
ThemeRepository $themeRepository,
EntityManagerInterface $em
) {
$this->themeRepository = $themeRepository;
$this->em = $em;
}
public function buildForm(FormBuilderInterface $builder, array $options) {
$builder
->add('parent', TextType::class, [
'invalid_message' => 'No such theme.',
'label' => 'label.parent_theme',
'required' => false,
])
->add('name', TextType::class, [
'label' => 'label.name',
])
......@@ -36,6 +65,39 @@ class ThemeType extends AbstractType {
'label' => 'label.comment',
'required' => false,
]);
$builder->get('parent')->addModelTransformer(new CallbackTransformer(
function ($value) {
if ($value instanceof ThemeRevision) {
return $value->getId()->toString();
}
return '';
},
function ($value) {
$value = trim($value);
if ($value === '') {
return null;
}
if (preg_match('!^(\w{3,25})\s*/\s*(.+)$!', $value, $matches)) {
list (, $username, $name) = $matches;
$theme = $this->themeRepository->findOneByUsernameAndName($username, $name);
$revision = $theme ? $theme->getLatestRevision() : null;
} elseif (Uuid::isValid(trim($value))) {
$revision = $this->em->find(ThemeRevision::class, $value);
}
if (empty($revision)) {
throw new TransformationFailedException();
}
return $revision;
}
));
}
public function configureOptions(OptionsResolver $resolver) {
......
......@@ -40,11 +40,11 @@ class ThemeRepository extends EntityRepository {
}
return $this->createQueryBuilder('t')
->where('t.author = (SELECT IDENTITY(u) FROM '.User::class.' WHERE username = :username)')
->where('t.author = (SELECT u FROM '.User::class.' u WHERE u.username = :username)')
->andWhere('t.name = :name')
->setParameter('username', $username)
->setParameter('name', $name)
->getQuery()
->getSingleResult();
->getOneOrNullResult();
}
}
......@@ -166,6 +166,8 @@ help:
append_to_default_style: Leave this checked unless your CSS provides a complete stylesheet for the entire site.
block_users: Blocking users prevents them from sending you private messages, and you won't receive notifications when they reply to you.
revision_summary: Enter a summary of your changes.
parent_theme_input: Enter the <username>/<theme> of the theme you wish to extend. Alternatively, you can enter a UUID to extend a particular revision.
append_to_default_style_inheritance_warning: Setting this has no effect when extending an existing theme.
inbox:
title: Inbox
......@@ -200,6 +202,7 @@ label:
blocked: Blocked
comment: Comment
last_revised: Last revised
parent_theme: Parent theme
login_form:
log_in: Log in
......
......@@ -15,3 +15,5 @@
'You cannot post more. Wait a while before trying again.': 'You cannot post more. Wait a while before trying again.'
'That name is already taken.': 'That name is already taken.'
'A forum by that name already exists.': 'A forum by that name already exists.'
'No such theme.': 'No such theme.'
'That theme cannot be extended.': 'That theme cannot be extended.'
{% set revision = revision ?? theme.latestRevision ?? null %}
{% set hierarchy = revision.hierarchy ?? theme.latestRevision.hierarchy ?? [] %}
{% if revision is null or revision.appendToDefaultStyle %}
{% if hierarchy[0].appendToDefaultStyle ?? true %}
{% if not night_mode %}
<link rel="stylesheet" href="{{ preload(asset('build/red.css')) }}">
{% else %}
......@@ -8,9 +8,11 @@
{% endif %}
{% endif %}
{% if revision is not null %}
<link rel="stylesheet"
href="{{ path('raddit_app_stylesheet', {themeId: revision.id, field: 'common'}) }}">
<link rel="stylesheet"
href="{{ path('raddit_app_stylesheet', {themeId: revision.id, field: night_mode ? 'night' : 'day'}) }}">
{% endif %}
{% for revision in hierarchy %}
{% if revision.commonCss is not null %}
<link rel="stylesheet" href="{{ path('raddit_app_stylesheet', {themeId: revision.id, field: 'common'}) }}">
{% endif %}
{% if (night_mode ? revision.nightCss ? revision.dayCss) is not null %}
<link rel="stylesheet" href="{{ path('raddit_app_stylesheet', {themeId: revision.id, field: night_mode ? 'night' : 'day'}) }}">
{% endif %}
{% endfor %}
{{ form_start(form) }}
{% if form.parent is defined %}
<div class="form__row">
{{ form_label(form.parent) }}
{{ form_errors(form.parent) }}
{{ form_widget(form.parent) }}
<div class="form__help">
<p>{{ 'help.parent_theme_input'|trans }}</p>
</div>
</div>
{% endif %}
{{ form_row(form.name) }}
{{ form_row(form.commonCss, { attr: { rows: 12 } }) }}
......@@ -22,6 +34,7 @@
{{ form_widget(form.appendToDefaultStyle) }}
<div class="form__help">
<p>{{ 'help.append_to_default_style'|trans }}</p>
<p>{{ 'help.append_to_default_style_inheritance_warning'|trans }}</p>
</div>
</div>
......
<?php
namespace Raddit\Tests\AppBundle\Entity;
use PHPUnit\Framework\TestCase;
use Raddit\AppBundle\Entity\Theme;
use Raddit\AppBundle\Entity\ThemeRevision;
class ThemeRevisionTest extends TestCase {
public function testCannotHaveMoreThanThreeParents() {
$theme = $this->createMock(Theme::class);
$p = new ThemeRevision($theme, 'a{}', null, null, true, '');
$p = new ThemeRevision($theme, 'ins{}', null, null, true, '', $p);
$p = new ThemeRevision($theme, 'del{}', null, null, true, '', $p);
$p = new ThemeRevision($theme, 'span{}', null, null, true, '', $p);
$this->expectException(\DomainException::class);
new ThemeRevision($theme, 'div{}', null, null, true, '', $p);
}
public function testCountParents() {
$theme = $this->createMock(Theme::class);
$p1 = new ThemeRevision($theme, 'a', null, null, true, '');
$p2 = new ThemeRevision($theme, 'a', null, null, true, '', $p1);
$p3 = new ThemeRevision($theme, 'a', null, null, true, '', $p2);
$p4 = new ThemeRevision($theme, 'a', null, null, true, '', $p3);
$this->assertEquals(0, $p1->getParentCount());
$this->assertEquals(1, $p2->getParentCount());
$this->assertEquals(2, $p3->getParentCount());
$this->assertEquals(3, $p4->getParentCount());
}
public function testGetHierarchy() {
$theme = $this->createMock(Theme::class);
$p1 = new ThemeRevision($theme, 'a{}', null, null, true, '');
$p2 = new ThemeRevision($theme, 'ins{}', null, null, true, '', $p1);
$p3 = new ThemeRevision($theme, 'del{}', null, null, true, '', $p2);
$p4 = new ThemeRevision($theme, 'span{}', null, null, true, '', $p3);
$this->assertEquals([$p1], $p1->getHierarchy());
$this->assertEquals([$p1, $p2], $p2->getHierarchy());
$this->assertEquals([$p1, $p2, $p3], $p3->getHierarchy());
$this->assertEquals([$p1, $p2, $p3, $p4], $p4->getHierarchy());
}
}
......@@ -32,7 +32,7 @@ class ThemeTest extends TestCase {
public function testGetsLatestRevisionCorrectly() {
$theme = new Theme('a', new User(), 'body{}', null, null, true, 'c');
$theme->addRevision(new ThemeRevision($theme, null, 'body{}', null, true, 'c', new \DateTime('yesterday')));
$theme->addRevision(new ThemeRevision($theme, null, 'body{}', null, true, 'c', null, new \DateTime('yesterday')));
$this->assertSame('body{}', $theme->getLatestRevision()->getCommonCss());
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment