Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
60 changes: 58 additions & 2 deletions resources/views/admin/posts/create.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@

<div class="mb-3">
<label for="slug" class="form-label">Slug</label>
<input type="text" class="form-control" id="slug" name="slug" required>
<small class="form-text text-muted">URL-friendly version of the title</small>
<input type="text" class="form-control" id="slug" name="slug" pattern="[a-z0-9-]+">
<small class="form-text text-muted">URL-friendly version. Leave blank to auto-generate from title.</small>
</div>

<div class="mb-3">
Expand All @@ -39,9 +39,16 @@
<select class="form-select" id="status" name="status" required>
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="scheduled">Scheduled</option>
</select>
</div>

<div class="mb-3" id="published-at-wrapper" style="display: none;">
<label for="published_at" class="form-label">Published Date/Time</label>
<input type="datetime-local" class="form-control" id="published_at" name="published_at">
<small class="form-text text-muted">Required for scheduled posts. Leave blank to use current date/time when publishing.</small>
</div>

<div class="mb-3">
<label for="featured_image" class="form-label">Featured Image</label>
<div class="input-group">
Expand Down Expand Up @@ -202,6 +209,15 @@ class: Embed,
document.getElementById('post-form').addEventListener('submit', async (e) => {
e.preventDefault();

// Validate scheduled posts have a published date
const status = document.getElementById('status').value;
const publishedAt = document.getElementById('published_at').value;

if (status === 'scheduled' && !publishedAt) {
alert('Scheduled posts require a published date/time.');
return;
}

try {
const savedData = await editor.save();
console.log('Saved data:', savedData);
Expand All @@ -218,6 +234,46 @@ class: Embed,
}
}

// Auto-generate slug from title
document.getElementById('title').addEventListener('input', function() {
const slugInput = document.getElementById('slug');
if (!slugInput.value || slugInput.dataset.autoGenerated === 'true') {
const slug = this.value.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
slugInput.value = slug;
slugInput.dataset.autoGenerated = 'true';
}
});

// Mark slug as manually edited
document.getElementById('slug').addEventListener('input', function() {
if (this.value) {
this.dataset.autoGenerated = 'false';
}
});

// Show/hide published date based on status
function updatePublishedAtVisibility() {
const status = document.getElementById('status').value;
const publishedAtWrapper = document.getElementById('published-at-wrapper');
const publishedAtInput = document.getElementById('published_at');

if (status === 'published' || status === 'scheduled') {
publishedAtWrapper.style.display = 'block';
publishedAtInput.disabled = false;
} else {
publishedAtWrapper.style.display = 'none';
publishedAtInput.value = '';
publishedAtInput.disabled = true;
}
}

document.getElementById('status').addEventListener('change', updatePublishedAtVisibility);

// Initialize on page load
document.addEventListener('DOMContentLoaded', updatePublishedAtVisibility);

// Featured image preview
document.getElementById('featured_image').addEventListener('change', function() {
const preview = document.getElementById('featured_image_preview');
Expand Down
60 changes: 58 additions & 2 deletions resources/views/admin/posts/edit.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@

<div class="mb-3">
<label for="slug" class="form-label">Slug</label>
<input type="text" class="form-control" id="slug" name="slug" value="<?= htmlspecialchars( $post->getSlug() ) ?>" required>
<small class="form-text text-muted">URL-friendly version of the title</small>
<input type="text" class="form-control" id="slug" name="slug" value="<?= htmlspecialchars( $post->getSlug() ) ?>" pattern="[a-z0-9-]+">
<small class="form-text text-muted">URL-friendly version. Leave blank to auto-generate from title.</small>
</div>

<div class="mb-3">
Expand All @@ -40,9 +40,16 @@
<select class="form-select" id="status" name="status" required>
<option value="draft" <?= $post->getStatus() === 'draft' ? 'selected' : '' ?>>Draft</option>
<option value="published" <?= $post->getStatus() === 'published' ? 'selected' : '' ?>>Published</option>
<option value="scheduled" <?= $post->getStatus() === 'scheduled' ? 'selected' : '' ?>>Scheduled</option>
</select>
</div>

<div class="mb-3" id="published-at-wrapper" style="display: <?= in_array($post->getStatus(), ['published', 'scheduled']) ? 'block' : 'none' ?>;">
<label for="published_at" class="form-label">Published Date/Time</label>
<input type="datetime-local" class="form-control" id="published_at" name="published_at" value="<?= $post->getPublishedAt() ? $post->getPublishedAt()->format('Y-m-d\TH:i') : '' ?>">
<small class="form-text text-muted">Required for scheduled posts. Leave blank to use current date/time when publishing.</small>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.

<div class="mb-3">
<label for="featured_image" class="form-label">Featured Image</label>
<div class="input-group">
Expand Down Expand Up @@ -228,6 +235,15 @@ class: Embed,
document.getElementById('post-form').addEventListener('submit', async (e) => {
e.preventDefault();

// Validate scheduled posts have a published date
const status = document.getElementById('status').value;
const publishedAt = document.getElementById('published_at').value;

if (status === 'scheduled' && !publishedAt) {
alert('Scheduled posts require a published date/time.');
return;
}

try {
const savedData = await editor.save();
console.log('Saved data:', savedData);
Expand All @@ -244,6 +260,46 @@ class: Embed,
}
}

// Auto-generate slug from title
document.getElementById('title').addEventListener('input', function() {
const slugInput = document.getElementById('slug');
if (!slugInput.value || slugInput.dataset.autoGenerated === 'true') {
const slug = this.value.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
slugInput.value = slug;
slugInput.dataset.autoGenerated = 'true';
}
});

// Mark slug as manually edited
document.getElementById('slug').addEventListener('input', function() {
if (this.value) {
this.dataset.autoGenerated = 'false';
}
});

// Show/hide published date based on status
function updatePublishedAtVisibility() {
const status = document.getElementById('status').value;
const publishedAtWrapper = document.getElementById('published-at-wrapper');
const publishedAtInput = document.getElementById('published_at');

if (status === 'published' || status === 'scheduled') {
publishedAtWrapper.style.display = 'block';
publishedAtInput.disabled = false;
} else {
publishedAtWrapper.style.display = 'none';
publishedAtInput.value = '';
publishedAtInput.disabled = true;
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

document.getElementById('status').addEventListener('change', updatePublishedAtVisibility);

// Initialize on page load
document.addEventListener('DOMContentLoaded', updatePublishedAtVisibility);

// Featured image preview
document.getElementById('featured_image').addEventListener('change', function() {
const preview = document.getElementById('featured_image_preview');
Expand Down
5 changes: 5 additions & 0 deletions src/Cms/Dtos/posts/create-post-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,8 @@ dto:
type: string
required: true
enum: ['draft', 'published', 'scheduled']

published_at:
type: string
required: false
pattern: '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$|^$/' # datetime-local format: YYYY-MM-DDTHH:MM or empty
5 changes: 5 additions & 0 deletions src/Cms/Dtos/posts/update-post-request.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,8 @@ dto:
type: string
required: true
enum: ['draft', 'published', 'scheduled']

published_at:
type: string
required: false
pattern: '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$|^$/' # datetime-local format: YYYY-MM-DDTHH:MM or empty
20 changes: 18 additions & 2 deletions src/Cms/Services/Post/Creator.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public function create( Dto $request, array $categoryIds = [], string $tagNames
$slug = $request->slug ?? null;
$excerpt = $request->excerpt ?? null;
$featuredImage = $request->featured_image ?? null;
$publishedAt = $request->published_at ?? null;

$post = new Post();
$post->setTitle( $title );
Expand All @@ -67,9 +68,24 @@ public function create( Dto $request, array $categoryIds = [], string $tagNames
$post->setStatus( $status );
$post->setCreatedAt( new DateTimeImmutable() );

// Business rule: auto-set published date for published posts
if( $status === ContentStatus::PUBLISHED->value )
// Business rule: set published date
if( $status === ContentStatus::SCHEDULED->value )
{
// Scheduled posts MUST have a published date
if( !$publishedAt || trim( $publishedAt ) === '' )
{
throw new \InvalidArgumentException( 'Scheduled posts require a published date' );
}
$post->setPublishedAt( new DateTimeImmutable( $publishedAt ) );
}
Comment thread
ljonesfl marked this conversation as resolved.
elseif( $publishedAt && trim( $publishedAt ) !== '' )
{
// Use provided published date
$post->setPublishedAt( new DateTimeImmutable( $publishedAt ) );
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}
elseif( $status === ContentStatus::PUBLISHED->value )
{
// Auto-set to now for published posts when not provided
Comment thread
cursor[bot] marked this conversation as resolved.
$post->setPublishedAt( new DateTimeImmutable() );
}

Expand Down
20 changes: 18 additions & 2 deletions src/Cms/Services/Post/Updater.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public function update( Dto $request, array $categoryIds = [], string $tagNames
$slug = $request->slug ?? null;
$excerpt = $request->excerpt ?? null;
$featuredImage = $request->featured_image ?? null;
$publishedAt = $request->published_at ?? null;

// Look up the post
$post = $this->_postRepository->findById( $id );
Expand All @@ -71,9 +72,24 @@ public function update( Dto $request, array $categoryIds = [], string $tagNames
$post->setFeaturedImage( $featuredImage );
$post->setStatus( $status );

// Business rule: auto-set published date when changing to published status
if( $status === ContentStatus::PUBLISHED->value && !$post->getPublishedAt() )
// Business rule: set published date
if( $status === ContentStatus::SCHEDULED->value )
{
// Scheduled posts MUST have a published date
if( !$publishedAt || trim( $publishedAt ) === '' )
{
throw new \InvalidArgumentException( 'Scheduled posts require a published date' );
}
$post->setPublishedAt( new \DateTimeImmutable( $publishedAt ) );
}
elseif( $publishedAt && trim( $publishedAt ) !== '' )
{
// Use provided published date
$post->setPublishedAt( new \DateTimeImmutable( $publishedAt ) );
}
elseif( $status === ContentStatus::PUBLISHED->value && !$post->getPublishedAt() )
{
// Auto-set to now for published posts when not provided and not already set
$post->setPublishedAt( new \DateTimeImmutable() );
}

Expand Down
64 changes: 63 additions & 1 deletion tests/Unit/Cms/Services/Post/CreatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ private function createDto(
string $status,
?string $slug = null,
?string $excerpt = null,
?string $featuredImage = null
?string $featuredImage = null,
?string $publishedAt = null
): Dto
{
$factory = new Factory( __DIR__ . "/../../../../../src/Cms/Dtos/posts/create-post-request.yaml" );
Expand All @@ -67,6 +68,10 @@ private function createDto(
{
$dto->featured_image = $featuredImage;
}
if( $publishedAt !== null )
{
$dto->published_at = $publishedAt;
}

return $dto;
}
Expand Down Expand Up @@ -353,4 +358,61 @@ public function testSetsOptionalFields(): void
$this->assertEquals( 'Test excerpt', $result->getExcerpt() );
$this->assertEquals( 'image.jpg', $result->getFeaturedImage() );
}

public function testScheduledPostRequiresPublishedDate(): void
{
$this->expectException( \InvalidArgumentException::class );
$this->expectExceptionMessage( 'Scheduled posts require a published date' );

$dto = $this->createDto(
'Scheduled Post',
'{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}',
1,
'scheduled', // Scheduled status
null, // No slug
null, // No excerpt
null, // No featured image
null // No published date - THIS SHOULD THROW EXCEPTION
);

$this->_creator->create( $dto );
}

public function testScheduledPostWithPublishedDateSucceeds(): void
{
$this->_mockCategoryRepository
->method( 'findByIds' )
->willReturn( [] );

$this->_mockTagResolver
->method( 'resolveFromString' )
->willReturn( [] );

$publishedDate = '2025-12-31T23:59';

$this->_mockPostRepository
->expects( $this->once() )
->method( 'create' )
->with( $this->callback( function( Post $post ) {
return $post->getStatus() === 'scheduled'
&& $post->getPublishedAt() instanceof DateTimeImmutable;
} ) )
->willReturnArgument( 0 );

$dto = $this->createDto(
'Scheduled Post',
'{"blocks":[{"type":"paragraph","data":{"text":"Body"}}]}',
1,
'scheduled',
null,
null,
null,
$publishedDate
);

$result = $this->_creator->create( $dto );

$this->assertEquals( 'scheduled', $result->getStatus() );
$this->assertInstanceOf( DateTimeImmutable::class, $result->getPublishedAt() );
}
}
Loading