diff --git a/front/js/common/Icon.svelte b/front/js/common/Icon.svelte index 66000bc..991ae02 100644 --- a/front/js/common/Icon.svelte +++ b/front/js/common/Icon.svelte @@ -56,23 +56,23 @@ path: '', viewBox: "0 0 576 512", }, - "filter": { + filter: { path: '', viewBox: "0 0 512 512", }, - "calendar": { + calendar: { path: '', viewBox: "0 0 448 512", }, - "pencil": { + pencil: { path: '', viewBox: "0 0 512 512", }, - "database": { + database: { path: '', viewBox: "0 0 448 512", }, - "dice": { + dice: { path: '', viewBox: "0 0 640 512", }, @@ -81,7 +81,7 @@ path: '', viewBox: "0 0 512 512", }, - "eye": { + eye: { path: '', viewBox: "0 0 576 512", }, @@ -93,19 +93,19 @@ path: '', viewBox: "0 0 512 512", }, - "expand": { + expand: { path: '', viewBox: "0 0 448 512", }, - "compress": { + compress: { path: '', viewBox: "0 0 448 512", }, - "check": { + check: { path: '', viewBox: "0 0 448 512", }, - "close": { + close: { path: '', viewBox: "0 0 24 24", }, @@ -113,7 +113,7 @@ path: '', viewBox: "0 0 24 24", }, - "list": { + list: { path: '', viewBox: "0 0 24 24", }, @@ -121,33 +121,33 @@ path: '', viewBox: "0 0 24 24", }, - "italic": { + italic: { path: '', viewBox: "0 0 24 24", - } + }, }; - - export let width = 16; - export let height = 16; - export let icon = ""; - export let fill = "currentColor"; - export let stroke = "currentColor"; + let { + width = 16, + height = 16, + icon, + fill = "currentColor", + stroke = "currentColor", + } = $props(); let selectedIcon = icons[icon]; {@html selectedIcon.path} @@ -155,6 +155,5 @@ diff --git a/front/js/entry/RecordEditEntry/PublishingOptions.svelte b/front/js/entry/RecordEditEntry/PublishingOptions.svelte new file mode 100644 index 0000000..43a4ae9 --- /dev/null +++ b/front/js/entry/RecordEditEntry/PublishingOptions.svelte @@ -0,0 +1,60 @@ + + +
+ {#if status != "trashed"} + + {/if} + +
diff --git a/front/js/entry/RecordEditEntry/RecordEditEntry.svelte b/front/js/entry/RecordEditEntry/RecordEditEntry.svelte index 4358872..03cc821 100644 --- a/front/js/entry/RecordEditEntry/RecordEditEntry.svelte +++ b/front/js/entry/RecordEditEntry/RecordEditEntry.svelte @@ -3,6 +3,8 @@ import { onMount } from "svelte"; import TextField from "./fields/TextField.svelte"; import LocaleChooser from "./LocaleChooser.svelte"; + import RecordStatus from "./RecordStatus.svelte"; + import PublishingOptions from "./PublishingOptions.svelte"; import { getSelectedLocales } from "./locale.svelte.js"; let { channel, user, data } = $props(); let selectedLocales = $state(getSelectedLocales()); @@ -16,42 +18,52 @@ {#snippet body()} - - {#each data.fields as field} -
- {#if field.type === "text"} - f.id === field.fieldId && f.locale === "main", - )} - schemaField={field} - locale="main" - dataField={data.draftData.find( - (f) => f.id === field.id && f.locale === "main", - )} - > - {#if field.translatable} - {#each selectedLocales as locale (locale)} - - f.fieldId === field.id && - f.locale === locale, - )} - schemaField={field} - {locale} - dataField={data.draftData.find( - (f) => f.id === field.id && f.locale === locale, - )} - > - {/each} + +
+ + +
+
+ {#each data.fields as field} +
+ {#if field.type === "text"} + + f.fieldId === field.id && f.locale === "main", + )} + schemaField={field} + locale="main" + dataField={data.draftData.find( + (f) => f.id === field.id && f.locale === "main", + )} + > + {#if field.translatable} + {#each selectedLocales as locale (locale)} + + f.fieldId === field.id && + f.locale === locale, + )} + schemaField={field} + {locale} + dataField={data.draftData.find( + (f) => + f.id === field.id && + f.locale === locale, + )} + > + {/each} + {/if} {/if} - {/if} -
- {/each} +
+ {/each} + {/snippet} diff --git a/front/js/entry/RecordEditEntry/RecordStatus.svelte b/front/js/entry/RecordEditEntry/RecordStatus.svelte new file mode 100644 index 0000000..b12e1f0 --- /dev/null +++ b/front/js/entry/RecordEditEntry/RecordStatus.svelte @@ -0,0 +1,84 @@ + + +
+ {#if status === "trashed"} + This record is Trashed + + {:else if status === "scheduled_and_published"} +
+
+ + This record was published at {record.publishedAt} + + +
+
+ + It is scheduled to be republished at {record.scheduledAt} + + +
+
+ {:else if status === "published"} + + This record was published at {record.publishedAt} + + + {:else if status === "scheduled"} + + This record is scheduled to be published at {record.scheduledAt} + + + {:else} + This record is a draft. Not yet published + {/if} +
+ + diff --git a/front/js/entry/RecordEditEntry/fields/TextField.svelte b/front/js/entry/RecordEditEntry/fields/TextField.svelte index 54e84ea..f5512d9 100644 --- a/front/js/entry/RecordEditEntry/fields/TextField.svelte +++ b/front/js/entry/RecordEditEntry/fields/TextField.svelte @@ -2,13 +2,14 @@ import { post } from "../../../modules/remote"; import { getApp } from "../../../app"; import { getLocaleName } from "../locale.svelte.js"; - let { channel, record, schemaField, dataField, locale, errors } = $props(); + let { channel, record, schemaField, dataField, locale, validationError } = + $props(); let originalValue = dataField?.value ?? schemaField.props.default; let newValue = $state(originalValue); let valuesChanged = $derived(newValue !== originalValue); let errorMessage = $state(""); - let validationErrors = $state(errors); - let hasErrors = $derived(validationErrors.length > 0); + // let validationErrorState = $state(validationError); + let hasError = $derived(!!validationError); const app = getApp(); function save() { @@ -28,7 +29,7 @@ if (err.isNotEmpty()) { errorMessage = err.first(); } else { - validationErrors = data.validationErrors; + validationError = data.validationError; originalValue = newValue; } }, @@ -119,11 +120,11 @@ onblur={handleBlur} oncompositionstart={handleCompositionStart} oncompositionend={handleCompositionEnd} - aria-invalid={hasErrors ? "true" : ""} + aria-invalid={hasError ? "true" : ""} /> - {#if hasErrors} + {#if hasError} {validationErrors[0].message}{validationError.message} {:else if schemaField.help != ""} {schemaField.help} diff --git a/src/Core/Data/Record.php b/src/Core/Data/Record.php index 0ccff81..f1b9911 100644 --- a/src/Core/Data/Record.php +++ b/src/Core/Data/Record.php @@ -4,10 +4,6 @@ use Carbon\Carbon; class Record { - /** - * @param RecordField[] $draftData - * @param RecordField[] $liveData - */ public function __construct( public string $id, public string $schemaId, @@ -15,6 +11,8 @@ class Record public string $createdBy, public ?Carbon $publishedAt, public ?string $publishedBy, + public ?Carbon $scheduledAt, + public ?string $scheduledBy, public ?Carbon $trashedAt, public ?string $trashedBy, ) {} diff --git a/src/Core/Data/RecordStatus.php b/src/Core/Data/RecordStatus.php new file mode 100644 index 0000000..1abbebc --- /dev/null +++ b/src/Core/Data/RecordStatus.php @@ -0,0 +1,10 @@ +where("mode", RecordMode::DRAFT) + ->values() + ->map(function (RecordField $f) { + $f->mode = RecordMode::LIVE; + return $f; + }) + ->toArray(); + + DB::transaction(function () use ($recordField) { + RecordFieldRepo::deleteLiveByRecordId($recordId); + RecordFieldRepo::insertMany($liveData); + }); + } + + public static function unpublish(string $recordId): void + { + RecordFieldRepo::deleteLiveByRecordId($recordId); + } + public static function fromDb(stdClass $field): RecordField { return new RecordField( diff --git a/src/Core/Record/RecordModule.php b/src/Core/Record/RecordModule.php index a32b55a..7647f48 100644 --- a/src/Core/Record/RecordModule.php +++ b/src/Core/Record/RecordModule.php @@ -3,22 +3,23 @@ use Carbon\Carbon; use Lucent\Core\Data\Record; use Lucent\Core\Data\RecordField; +use Lucent\Core\Data\RecordStatus; use stdClass; class RecordModule { - public static function updateField( - Record $record, - RecordField $field, - ): Record { - $recordFields = collect($record->draftData)->filter( - fn(RecordField $rf) => !( - $rf->id == $field->id && $rf->locale == $field->locale - ), - ); - $recordFields->push($field); - $record->draftData = $recordFields->values()->toArray(); - return $record; + public static function getStatus(Record $record): RecordStatus + { + if ($record->trashedBy) { + return RecordStatus::TRASHED; + } elseif ($record->publishedBy && $record->scheduledAt) { + return RecordStatus::SCHEDULED_AND_PUBLISHED; + } elseif ($record->publishedBy) { + return RecordStatus::PUBLISHED; + } elseif ($record->scheduledAt) { + return RecordStatus::SCHEDULED; + } + return RecordStatus::DRAFT; } public static function toDb(Record $record): array @@ -30,11 +31,15 @@ class RecordModule "created_by" => $record->createdBy, "published_at" => empty($record->publishedAt) ? null - : record->publishedAt->toJSON(), + : $record->publishedAt->toJSON(), "published_by" => $record->publishedBy, + "scheduled_at" => empty($record->scheduledAt) + ? null + : $record->scheduledAt->toJSON(), + "scheduled_by" => $record->scheduledBy, "trashed_at" => empty($record->trashedAt) ? null - : record->trashedAt->toJSON(), + : $record->trashedAt->toJSON(), "trashed_by" => $record->trashedBy, ]; } @@ -50,6 +55,10 @@ class RecordModule ? null : Carbon::parse(data_get($data, "published_at")), publishedBy: data_get($data, "published_by"), + scheduledAt: empty(data_get($data, "scheduled_at")) + ? null + : Carbon::parse(data_get($data, "scheduled_at")), + scheduledBy: data_get($data, "scheduled_by"), trashedAt: empty(data_get($data, "trashed_at")) ? null : Carbon::parse(data_get($data, "trashed_at")), diff --git a/src/Core/Record/RecordValidationModule.php b/src/Core/Record/RecordValidationModule.php index a5376ac..1fe74f2 100644 --- a/src/Core/Record/RecordValidationModule.php +++ b/src/Core/Record/RecordValidationModule.php @@ -1,5 +1,6 @@ translatable) { foreach ($locales as $locale) { $res = static::validateField( @@ -31,7 +32,7 @@ class RecordValidationModule $schemaField, $recordFields, ); - $errors = array_merge($errors, $res); + $errors[] = $res; } } } @@ -52,15 +53,27 @@ class RecordValidationModule string $locale, Field $schemaField, array $recordFields, - ): array { + ): ?RecordError { // General Validations $error = static::validateRequired($locale, $schemaField, $recordFields); + if (!empty($error)) { + return $error; + } // Type specific + $error = match ($schemaField->type) { + "text" => static::validateText( + $locale, + $schemaField, + $recordFields, + ), + default => static::validateText( + $locale, + $schemaField, + $recordFields, + ), + }; - return collect([$error]) - ->filter(fn($err) => $err !== null) - ->values() - ->toArray(); + return $error; } /** @@ -77,11 +90,7 @@ class RecordValidationModule if ($schemaField->required === false) { return null; } - $recordField = collect($recordFields)->first( - fn(RecordField $field) => $field->id === $schemaField->id && - $field->locale === $locale && - $field->mode === RecordMode::DRAFT, - ); + $recordField = static::findField($recordFields, $schemaField, $locale); if (empty($recordField) || empty($recordField->value)) { return new RecordError( @@ -93,4 +102,64 @@ class RecordValidationModule return null; } + + /** + * + * @param Field $schemaField + * @param RecordField[] $recordFields + * @return ?RecordError + */ + public static function validateText( + string $locale, + Field $schemaField, + array $recordFields, + ): ?RecordError { + $recordField = static::findField($recordFields, $schemaField, $locale); + + if (empty($recordField)) { + // We have already checked for required fields so only validate existing + return null; + } + + $rules = ""; + if ($schemaField->props->min > 0) { + $rules = "min:" . $schemaField->props->min; + } + if ($schemaField->props->max > 0) { + $rules = "max:" . $schemaField->props->max; + } + + if (empty($rules)) { + return null; + } + + $validator = Validator::make( + ["value" => $recordField->value], + [ + "value" => $rules, + ], + ); + + if ($validator->fails()) { + return new RecordError( + $schemaField->id, + $locale, + $validator->errors()->first(), + ); + } + + return null; + } + + private static function findField( + array $recordFields, + Field $schemaField, + string $locale, + ): ?RecordField { + return collect($recordFields)->first( + fn(RecordField $field) => $field->id === $schemaField->id && + $field->locale === $locale && + $field->mode === RecordMode::DRAFT, + ); + } } diff --git a/src/Core/Repository/RecordFieldRepo.php b/src/Core/Repository/RecordFieldRepo.php index c9deddf..f237668 100644 --- a/src/Core/Repository/RecordFieldRepo.php +++ b/src/Core/Repository/RecordFieldRepo.php @@ -2,6 +2,7 @@ use Illuminate\Support\Facades\DB; use Lucent\Core\Data\RecordField; +use Lucent\Core\Data\RecordMode; use Lucent\Core\Record\RecordFieldModule; class RecordFieldRepo @@ -15,6 +16,21 @@ class RecordFieldRepo ); } + public static function insertMany(array $recordFields): void + { + DB::table(self::TABLE_NAME)->insert( + array_map(RecordFieldModule::toDb(...), $recordFields), + ); + } + + public static function deleteLiveByRecordId(string $recordId): void + { + DB::table(self::TABLE_NAME) + ->where("record_id", $recordId) + ->where("mode", RecordMode::LIVE->value) + ->delete(); + } + public static function delete(RecordField $field): void { DB::table(self::TABLE_NAME) diff --git a/src/Core/Repository/RecordRepo.php b/src/Core/Repository/RecordRepo.php index 1f97fbe..1778ce0 100644 --- a/src/Core/Repository/RecordRepo.php +++ b/src/Core/Repository/RecordRepo.php @@ -13,6 +13,13 @@ class RecordRepo DB::table(self::TABLE_NAME)->insert(RecordModule::toDb($record)); } + public static function update(Record $record): void + { + DB::table(self::TABLE_NAME) + ->where("id", $record->id) + ->update(RecordModule::toDb($record)); + } + public static function findOne(string $id): ?Record { return DB::table(self::TABLE_NAME) diff --git a/src/Http/Controller/RecordController.php b/src/Http/Controller/RecordController.php index 5a5dc90..5db01e5 100644 --- a/src/Http/Controller/RecordController.php +++ b/src/Http/Controller/RecordController.php @@ -11,6 +11,7 @@ use Lucent\Core\Data\RecordField; use Lucent\Core\Repository\FieldRepo; use Lucent\Core\Repository\SchemaRepo; use Lucent\Core\Repository\RecordFieldRepo; +use Lucent\Core\Record\RecordModule; use Lucent\Id\Id; use Lucent\LucentException; use Lucent\Query\Operator\OperatorRegistry; @@ -28,6 +29,7 @@ use Lucent\Schema\Validator\ValidatorException; use Lucent\Svelte\Svelte; use Lucent\ViewModel\ViewModel; use Lucent\Core\Data\Record; +use Lucent\Core\Data\RecordStatus; use Lucent\Core\Data\RecordMode; use Illuminate\Support\Facades\DB; use function Lucent\Response\fail; @@ -265,6 +267,8 @@ class RecordController $draftData, ); + $recordStatus = RecordModule::getStatus($record); + return Svelte::view( view: "recordEdit", title: "Edit Record", @@ -276,6 +280,7 @@ class RecordController "record" => toArray($record), "draftData" => $draftData, "validationErrors" => $validationErrors, + "recordStatus" => $recordStatus, ], ); } @@ -325,6 +330,8 @@ class RecordController createdBy: $userId, publishedAt: null, publishedBy: null, + scheduledAt: null, + scheduledBy: null, trashedAt: null, trashedBy: null, ); @@ -371,7 +378,7 @@ class RecordController RecordFieldRepo::insert($recordField); }); - $validationErrors = RecordValidationModule::validateField( + $validationError = RecordValidationModule::validateField( $locale, $field, [$recordField], @@ -379,7 +386,7 @@ class RecordController return ok([ "recordField" => toArray($recordField), - "validationErrors" => $validationErrors, + "validationError" => $validationError, ]); } @@ -487,4 +494,100 @@ class RecordController } return ok(); } + + public function publish(Request $request) + { + $id = $request->input("id"); + $record = RecordRepo::findOne($id); + $recordStatus = RecordModule::getStatus($record); + if ($recordStatus == RecordStatus::TRASHED) { + return fail("Record is in trash"); + } + $userId = AuthModule::getCurrentUserId(); + $record->publishedAt = now(); + $record->publishedBy = $userId; + $record->scheduledAt = null; + $record->scheduledBy = null; + + RecordFieldModule::publish($record->id); + RecordRepo::update($record); + + return ok(toArray($record)); + } + + public function unpublish(Request $request) + { + $id = $request->input("id"); + $record = RecordRepo::findOne($id); + + $record->publishedAt = null; + $record->publishedBy = null; + + RecordFieldModule::unpublish($record->id); + RecordRepo::update($record); + + return ok(toArray($record)); + } + + public function schedule(Request $request) + { + $id = $request->input("id"); + $date = $request->input("date"); + $record = RecordRepo::findOne($id); + $recordStatus = RecordModule::getStatus($record); + $userId = AuthModule::getCurrentUserId(); + if ($recordStatus == RecordStatus::TRASHED) { + return fail("Record is in trash"); + } + $record->scheduledAt = Carbon::parse($date); + $record->scheduledBy = $userId; + + RecordRepo::update($record); + + return ok(toArray($record)); + } + + public function unschedule(Request $request) + { + $id = $request->input("id"); + $record = RecordRepo::findOne($id); + + $record->scheduledAt = null; + $record->scheduledBy = null; + + RecordRepo::update($record); + + return ok(toArray($record)); + } + + public function trash(Request $request) + { + $id = $request->input("id"); + $record = RecordRepo::findOne($id); + + $userId = AuthModule::getCurrentUserId(); + $record->trashedAt = now(); + $record->trashedBy = $userId; + $record->publishedAt = null; + $record->publishedBy = null; + $record->scheduledAt = null; + $record->scheduledBy = null; + + RecordRepo::update($record); + + return ok(toArray($record)); + } + + public function untrash(Request $request) + { + $id = $request->input("id"); + $record = RecordRepo::findOne($id); + + $record->trashedAt = null; + $record->trashedBy = null; + + RecordRepo::update($record); + + return ok(toArray($record)); + } } diff --git a/src/Http/web.php b/src/Http/web.php index 4ce87de..8453a01 100644 --- a/src/Http/web.php +++ b/src/Http/web.php @@ -76,6 +76,27 @@ Route::group( RecordController::class, "saveField", ]); + Route::post("records/publish", [ + RecordController::class, + "publish", + ]); + Route::post("records/unpublish", [ + RecordController::class, + "unpublish", + ]); + Route::post("records/schedule", [ + RecordController::class, + "schedule", + ]); + Route::post("records/unschedule", [ + RecordController::class, + "unschedule", + ]); + Route::post("records/trash", [RecordController::class, "trash"]); + Route::post("records/untrash", [ + RecordController::class, + "untrash", + ]); }); //OLD