edges wip

This commit is contained in:
2026-01-10 02:09:50 +02:00
parent 87ab1f57fd
commit ced6146266
19 changed files with 380 additions and 271 deletions
@@ -1,6 +1,7 @@
<script>
import SchemaLayout from "../../layouts/SchemaLayout.svelte";
import TextFieldProps from "./TextFieldProps.svelte";
import RelationFieldProps from "./RelationFieldProps.svelte";
import DeleteButton from "../../common/DeleteButton.svelte";
import { post } from "../../modules/remote";
import { getApp } from "../../app";
@@ -124,6 +125,9 @@
{#if data.field.type === "text"}
<TextFieldProps field={data.field}></TextFieldProps>
{:else if data.field.type === "relation"}
<RelationFieldProps field={data.field} schemas={data.schemas}
></RelationFieldProps>
{/if}
<button type="submit">Update</button>
@@ -0,0 +1,29 @@
<script>
let { field, schemas } = $props();
</script>
<fieldset>
<label>
Schemas
<select
bind:value={field.props.schemas}
aria-label="Select allowed schemas"
multiple
size="6"
>
<option disabled>Select allowed schemas </option>
{#each schemas as schema}
<option value={schema.id}>{schema.name}</option>
{/each}
</select>
</label>
<label>
Min characters
<input type="number" bind:value={field.props.min} />
</label>
<label>
Max characters
<input type="number" bind:value={field.props.max} />
</label>
</fieldset>
@@ -1,5 +1,6 @@
<script>
import TextField from "./fields/TextField.svelte";
import RelationField from "./fields/RelationField.svelte";
let {
fields,
record,
@@ -8,40 +9,58 @@
fieldData,
selectedLocales,
} = $props();
const findFieldValidationError = (field, locale) => {
return validationErrors.find(
(f) => f.fieldId === field.id && f.locale === locale,
);
};
const findDataField = (field, locale) => {
return fieldData.find(
(f) => f.fieldId === field.id && f.locale === locale,
);
};
</script>
{#each fields as field}
<div style="display:flex;gap:20px;">
{#if field.type === "text"}
<TextField
{channel}
{record}
validationError={validationErrors.find(
(f) => f.fieldId === field.id && f.locale === "main",
)}
schemaField={field}
locale="main"
dataField={fieldData.find(
(f) => f.id === field.id && f.locale === "main",
)}
></TextField>
{@render textField(field, "main")}
{#if field.translatable}
{#each selectedLocales as locale (locale)}
<TextField
{channel}
{record}
validationError={validationErrors.find(
(f) =>
f.fieldId === field.id && f.locale === locale,
)}
schemaField={field}
{locale}
dataField={fieldData.find(
(f) => f.id === field.id && f.locale === locale,
)}
></TextField>
{@render textField(field, locale)}
{/each}
{/if}
{/if}
{#if field.type === "relation"}
{@render relationField(field, "main")}
{#if field.translatable}
{#each selectedLocales as locale (locale)}
{@render relationField(field, locale)}
{/each}
{/if}
{/if}
</div>
{/each}
{#snippet textField(field, locale)}
<TextField
{channel}
{record}
validationError={findFieldValidationError(field, locale)}
schemaField={field}
{locale}
dataField={findDataField(field, locale)}
></TextField>
{/snippet}
{#snippet relationField(field, locale)}
<RelationField
{channel}
{record}
validationError={findFieldValidationError(field, locale)}
schemaField={field}
{locale}
dataField={findDataField(field, locale)}
></RelationField>
{/snippet}
@@ -0,0 +1,86 @@
<script>
import { get, post } from "../../../modules/remote";
import { getApp } from "../../../app";
import { getLocaleName } from "../locale.svelte.js";
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 validationErrorState = $state(validationError);
let hasError = $derived(!!validationError);
const app = getApp();
let suggestionsLoaded = $state(false);
let suggestions = $state([]);
function handleAddRecord(e) {
// Add logic to handle adding a record
if (suggestionsLoaded) {
return;
}
get(
app.url("records/suggest"),
{ schemas: schemaField.props.schemas },
(data, err) => {
suggestionsLoaded = true;
suggestions = data;
},
);
}
function save() {
if (!valuesChanged) {
return;
}
post(
app.url("records/fields"),
{
recordId: record.id,
id: schemaField.id,
locale: locale,
value: newValue,
},
(data, err) => {
if (err.isNotEmpty()) {
errorMessage = err.first();
} else {
validationError = data.validationError;
originalValue = newValue;
}
},
);
}
</script>
<div style="min-width: 400px;">
<label>
{#if locale !== "main"}
{getLocaleName(channel, locale)} >
{/if}
{schemaField.name} <br />
<details class="dropdown">
<summary onclick={handleAddRecord}>Add Record</summary>
{#if suggestionsLoaded}
<ul>
{#each suggestions as suggestion}
<li><a href="#">{suggestion.title}</a></li>
{/each}
<li><a href="#">More</a></li>
</ul>
{:else}
<progress />
{/if}
</details>
{#if hasError}
<small id={schemaField.id + "-help"}
>{validationError.message}</small
>
{:else if schemaField.help != ""}
<small id={schemaField.id + "-help"}>{schemaField.help}</small>
{/if}
</label>
</div>
+7 -14
View File
@@ -8,7 +8,8 @@
let newSchemaAlias = $state("");
let fields = $state(data.fields);
const app = getApp();
const createFieldUrl = (schema, type) =>
app.url(`fields/create?schema=${schema.id}&type=${type}`);
function handleSchemaCreate(e) {
e.preventDefault();
post(
@@ -113,24 +114,16 @@
<summary>Add field</summary>
<ul>
<li>
<a
href={app.url(
`fields/create?schema=${schema.id}&type=text`,
)}>Text</a
>
<a href={createFieldUrl(schema, "text")}>Text</a>
</li>
<li>
<a
href={`/fields/create?schema=${schema.id}&type=text`}
>File</a
>
<a href={createFieldUrl(schema, "file")}>File</a>
</li>
<li>
<a
href={`/fields/create?schema=${schema.id}&type=text`}
>Rich</a
>
<a href={createFieldUrl(schema, "relation")}>
Relation
</a>
</li>
</ul>
</details>
+1
View File
@@ -7,6 +7,7 @@ class Record
public function __construct(
public string $id,
public string $schemaId,
public string $titleFieldId,
public Carbon $createdAt,
public string $createdBy,
public ?Carbon $publishedAt,
+2 -1
View File
@@ -5,8 +5,9 @@ use Carbon\Carbon;
class RecordField
{
public function __construct(
public string $recordId,
public string $id,
public string $recordId,
public string $fieldId,
public string $locale,
public RecordMode $mode,
public mixed $value,
+10
View File
@@ -0,0 +1,10 @@
<?php namespace Lucent\Core\Data;
class RecordPreview
{
public function __construct(
public string $id,
public string $schemaId,
public string $title,
) {}
}
+4 -2
View File
@@ -35,8 +35,9 @@ class RecordFieldModule
public static function fromDb(stdClass $field): RecordField
{
return new RecordField(
recordId: data_get($field, "record_id"),
id: data_get($field, "id"),
recordId: data_get($field, "record_id"),
fieldId: data_get($field, "field_id"),
locale: data_get($field, "locale", []),
mode: RecordMode::from(data_get($field, "mode")),
value: data_get($field, "value"),
@@ -48,8 +49,9 @@ class RecordFieldModule
public static function toDb(RecordField $field): array
{
return [
"record_id" => $field->recordId,
"id" => $field->id,
"record_id" => $field->recordId,
"field_id" => $field->fieldId,
"locale" => $field->locale,
"mode" => $field->mode->value,
"value" => $field->value,
+12 -1
View File
@@ -2,7 +2,7 @@
use Carbon\Carbon;
use Lucent\Core\Data\Record;
use Lucent\Core\Data\RecordField;
use Lucent\Core\Data\RecordPreview;
use Lucent\Core\Data\RecordStatus;
use stdClass;
@@ -27,6 +27,7 @@ class RecordModule
return [
"id" => $record->id,
"schema_id" => $record->schemaId,
"title_field_id" => $record->titleFieldId,
"created_at" => $record->createdAt->toJSON(),
"created_by" => $record->createdBy,
"published_at" => empty($record->publishedAt)
@@ -49,6 +50,7 @@ class RecordModule
return new Record(
id: data_get($data, "id"),
schemaId: data_get($data, "schema_id"),
titleFieldId: data_get($data, "title_field_id"),
createdAt: Carbon::parse(data_get($data, "created_at")),
createdBy: data_get($data, "created_by"),
publishedAt: empty(data_get($data, "published_at"))
@@ -65,4 +67,13 @@ class RecordModule
trashedBy: data_get($data, "trashed_by"),
);
}
public static function recordPreviewFromDb(stdClass $data): RecordPreview
{
return new RecordPreview(
id: data_get($data, "id"),
schemaId: data_get($data, "schema_id"),
title: data_get($data, "title"),
);
}
}
+1 -1
View File
@@ -157,7 +157,7 @@ class RecordValidationModule
string $locale,
): ?RecordField {
return collect($recordFields)->first(
fn(RecordField $field) => $field->id === $schemaField->id &&
fn(RecordField $field) => $field->fieldId === $schemaField->id &&
$field->locale === $locale &&
$field->mode === RecordMode::DRAFT,
);
+2 -2
View File
@@ -34,7 +34,7 @@ class RecordFieldRepo
public static function delete(RecordField $field): void
{
DB::table(self::TABLE_NAME)
->where("id", $field->id)
->where("field_id", $field->fieldId)
->where("locale", $field->locale)
->where("mode", $field->mode->value)
->where("record_id", $field->recordId)
@@ -44,7 +44,7 @@ class RecordFieldRepo
public static function findOne(string $id): ?RecordField
{
return DB::table(self::TABLE_NAME)
->where("id", $id)
->where("field_id", $id)
->get()
->map(RecordFieldModule::fromDb(...))
->first();
+30
View File
@@ -2,6 +2,7 @@
use Illuminate\Support\Facades\DB;
use Lucent\Core\Data\Record;
use Lucent\Core\Data\RecordPreview;
use Lucent\Core\Record\RecordModule;
class RecordRepo
@@ -28,4 +29,33 @@ class RecordRepo
->map(RecordModule::fromDb(...))
->first();
}
/*
* @return RecordPreview[]
*/
public static function findBySchemas(array $schemas): array
{
return DB::table(self::TABLE_NAME)
->select(
"records.id",
"records.schema_id",
"records_data.value as title",
)
->whereIn("schema_id", $schemas)
->join("records_data", function ($join) {
$join
->on("records.id", "=", "records_data.record_id")
->on(
"records.title_field_id",
"=",
"records_data.field_id",
);
})
->orderBy("created_at", "desc")
->limit(5)
->get()
->map(RecordModule::recordPreviewFromDb(...))
->toArray();
}
}
@@ -6,6 +6,7 @@ class FieldProp
{
return match ($type) {
"text" => new TextFieldProp(min: 0, max: 0, default: ""),
"relation" => new RelationFieldProp(schemas: [], min: 0, max: 0),
default => new InvalidFieldProp(),
};
}
@@ -24,6 +25,11 @@ class FieldProp
max: $props["max"] ?? 0,
default: $props["default"] ?? "",
),
"relation" => new RelationFieldProp(
schemas: $props["schemas"] ?? [],
min: $props["min"] ?? 0,
max: $props["max"] ?? 0,
),
default => new InvalidFieldProp(),
};
}
@@ -0,0 +1,10 @@
<?php namespace Lucent\Core\Schema\Data\FieldProp;
class RelationFieldProp implements IFieldProp
{
public function __construct(
public array $schemas,
public int $min,
public int $max,
) {}
}
+1 -32
View File
@@ -9,36 +9,5 @@ use Lucent\Query\Query;
class EdgeController extends Controller
{
public function __construct(
public EdgeService $edgeService,
public Query $query,
)
{
}
public function insertMany(Request $request)
{
$this->edgeService->createMany(
source: $request->input("source"),
sourceSchema: $request->input("sourceSchema"),
targetSchema: $request->input("targetSchema"),
field: $request->input("field"),
targets: $request->input("targets"),
);
$graph = $this->query
->filter(["id" => $request->input("source")])
->limit(1)
->skip(0)
->childrenDepth(2)
->childrenLimit(200)
->parentsDepth(1)
->parentsLimit(200)
->run();
return [
"graph" => toArray($graph),
];
}
public function postCreate(Request $request) {}
}
+102 -167
View File
@@ -136,96 +136,6 @@ class RecordController
);
}
public function exportCSV(Request $request)
{
$schemaName = $request->route("schemaName");
$schema = $this->channelService->channel->schemas
->where("name", $schemaName)
->first();
$urlParams = $request->all();
$sort = data_get($urlParams, "sort") ?? "-_sys.updatedAt";
$filter = data_get($urlParams, "filter") ?? [];
$arguments = array_merge(
[
"schema" => $schema->name,
"status_in" => "draft,published",
],
$filter,
);
$records = $this->query
->filter($arguments)
->limit(-1)
->status(explode(",", $arguments["status_in"]))
->childrenDepth(1)
// ->skip($skip)
->sort($sort)
->run()
->tree();
header("Content-Type: application/csv");
header(
'Content-Disposition: attachment; filename="' .
$schemaName .
'.csv";',
);
$handle = fopen("php://output", "w");
$relationColumns = $this->makeCsvRelationColumns($schema);
$csvRow = [
"id",
...array_keys($records[0]->data->toArray()),
...$relationColumns,
];
fputcsv($handle, $csvRow, ",");
foreach ($records as $record) {
$csvRow = [$record->id, ...$record->data->toArray()];
$csvRow = array_merge(
$csvRow,
$this->makeCsvRelationColumnValues($schema, $record->_children),
);
$csvRow = array_values($csvRow);
fputcsv($handle, $csvRow, ",");
}
fclose($handle);
echo $handle;
exit();
}
private function makeCsvRelationColumns($schema): array
{
return $schema->fields
->filter(fn($f) => get_class($f) === Reference::class)
->reduce(function ($c, $f) {
$c[] = $f->name . " id";
$c[] = $f->name . " name";
return $c;
}, []);
}
private function makeCsvRelationColumnValues($schema, $children): array
{
return $schema->fields
->filter(fn($f) => get_class($f) === Reference::class)
->reduce(function ($c, $f) use ($children) {
$fieldRecords = data_get($children, $f->name);
if (empty($fieldRecords)) {
$c[] = "";
$c[] = "";
} elseif (count($fieldRecords) === 1) {
$c[] = data_get($fieldRecords, "0.id");
$c[] = $this->viewModel->getRecordName($fieldRecords[0]);
} else {
$c[] = collect($fieldRecords)->pluck("id")->join("::");
$c[] = collect($fieldRecords)
->pluck("data.name")
->join("::");
}
return $c;
}, []);
}
public function edit(Request $request)
{
$recordId = $request->route("id");
@@ -291,34 +201,11 @@ class RecordController
);
}
public function suggestions(Request $request)
public function suggest(Request $request)
{
$arguments = [
"schema" => $request->input("schema"),
];
if ($request->input("value")) {
if (in_array($request->input("ui"), ["text", "date"])) {
$arguments[
"data." . $request->input("field") . "_regex"
] = $request->input("value");
} elseif ($request->input("ui") == "number") {
$arguments[
"data." . $request->input("field") . "_eqnum"
] = floatval($request->input("value"));
} elseif ($request->input("ui") == "date") {
} elseif ($request->input("ui") == "search") {
$arguments["search_regex"] = $request->input("value");
}
}
$records = $this->query->filter($arguments)->limit(10)->tree();
if ($records->isEmpty()) {
return ok([]);
}
return ok($records->toArray());
$schemas = $request->input("schemas");
$records = RecordRepo::findBySchemas($schemas);
return ok(toArray($records));
}
public function postCreate(Request $request)
@@ -328,10 +215,11 @@ class RecordController
$title = $request->input("title");
$now = Carbon::now();
$userId = AuthModule::getCurrentUserId();
$titleField = collect($fields)->where("alias", "_title")->first();
$record = new Record(
id: Id::new(),
schemaId: $schemaId,
titleFieldId: $titleField->id,
createdAt: $now,
createdBy: $userId,
publishedAt: null,
@@ -344,11 +232,10 @@ class RecordController
RecordRepo::insert($record);
$titleField = collect($fields)->where("alias", "_title")->first();
$titleFieldData = new RecordField(
id: Id::new(),
recordId: $record->id,
id: $titleField->id,
fieldId: $titleField->id,
locale: "main",
mode: RecordMode::DRAFT,
value: $title,
@@ -364,15 +251,16 @@ class RecordController
public function saveField(Request $request)
{
$recordId = $request->input("recordId");
$id = $request->input("id");
$fieldId = $request->input("id");
$locale = $request->input("locale");
$value = $request->input("value") ?? "";
$now = Carbon::now();
$field = FieldRepo::findOne($id);
$field = FieldRepo::findOne($fieldId);
$userId = AuthModule::getCurrentUserId();
$recordField = new RecordField(
id: Id::new(),
recordId: $recordId,
id: $field->id,
fieldId: $field->id,
locale: $locale,
mode: RecordMode::DRAFT,
value: $value,
@@ -396,49 +284,6 @@ class RecordController
]);
}
public function save(Request $request)
{
$recordId = $request->input("record.id");
try {
if ($request->input("isCreateMode")) {
$recordId = $this->recordService->create(
data: new RecordInputData(
$request->input("record.schema"),
$recordId ?? "",
$request->input("record.data"),
Status::from($request->input("record.status")),
),
edges: array_map(
EdgeInputData::fromArray(...),
$request->input("edges") ?? [],
),
);
} else {
$this->recordService->updateWithEdges(
id: $request->input("record.id"),
data: $request->input("record.data"),
status: Status::from($request->input("record.status")),
edges: array_map(
EdgeInputData::fromArray(...),
$request->input("edges") ?? [],
),
);
}
$newGraph = $this->query
->filter(["id" => $recordId])
->limit(10)
->childrenDepth(1)
->parentsDepth(1)
->run();
} catch (ValidatorException $th) {
return fail($th->getValidatorErrors());
} catch (LucentException $th) {
return fail($th);
}
return ok(toArray($newGraph));
}
public function clone(Request $request)
{
try {
@@ -596,4 +441,94 @@ class RecordController
return ok(toArray($record));
}
public function exportCSV(Request $request)
{
$schemaName = $request->route("schemaName");
$schema = $this->channelService->channel->schemas
->where("name", $schemaName)
->first();
$urlParams = $request->all();
$sort = data_get($urlParams, "sort") ?? "-_sys.updatedAt";
$filter = data_get($urlParams, "filter") ?? [];
$arguments = array_merge(
[
"schema" => $schema->name,
"status_in" => "draft,published",
],
$filter,
);
$records = $this->query
->filter($arguments)
->limit(-1)
->status(explode(",", $arguments["status_in"]))
->childrenDepth(1)
// ->skip($skip)
->sort($sort)
->run()
->tree();
header("Content-Type: application/csv");
header(
'Content-Disposition: attachment; filename="' .
$schemaName .
'.csv";',
);
$handle = fopen("php://output", "w");
$relationColumns = $this->makeCsvRelationColumns($schema);
$csvRow = [
"id",
...array_keys($records[0]->data->toArray()),
...$relationColumns,
];
fputcsv($handle, $csvRow, ",");
foreach ($records as $record) {
$csvRow = [$record->id, ...$record->data->toArray()];
$csvRow = array_merge(
$csvRow,
$this->makeCsvRelationColumnValues($schema, $record->_children),
);
$csvRow = array_values($csvRow);
fputcsv($handle, $csvRow, ",");
}
fclose($handle);
echo $handle;
exit();
}
private function makeCsvRelationColumns($schema): array
{
return $schema->fields
->filter(fn($f) => get_class($f) === Reference::class)
->reduce(function ($c, $f) {
$c[] = $f->name . " id";
$c[] = $f->name . " name";
return $c;
}, []);
}
private function makeCsvRelationColumnValues($schema, $children): array
{
return $schema->fields
->filter(fn($f) => get_class($f) === Reference::class)
->reduce(function ($c, $f) use ($children) {
$fieldRecords = data_get($children, $f->name);
if (empty($fieldRecords)) {
$c[] = "";
$c[] = "";
} elseif (count($fieldRecords) === 1) {
$c[] = data_get($fieldRecords, "0.id");
$c[] = $this->viewModel->getRecordName($fieldRecords[0]);
} else {
$c[] = collect($fieldRecords)->pluck("id")->join("::");
$c[] = collect($fieldRecords)
->pluck("data.name")
->join("::");
}
return $c;
}, []);
}
}
+1
View File
@@ -70,6 +70,7 @@ Route::group(
Route::get("content/{id}", [RecordController::class, "index"]);
// RECORD
Route::get("records/suggest", [RecordController::class, "suggest"]);
Route::get("records/{id}", [RecordController::class, "edit"]);
Route::post("records", [RecordController::class, "postCreate"]);
Route::post("records/fields", [
+28 -26
View File
@@ -8,30 +8,37 @@ use Illuminate\Support\Str;
class Record implements JsonSerializable
{
function __construct(
public string $id,
public string $schema,
public Status $status,
public System $_sys,
public string $id,
public string $schema,
public Status $status,
public System $_sys,
public RecordData $data,
public ?FileData $_file = null,
)
public ?FileData $_file = null,
) {}
private function indexValues(array $arrObject)
{
}
private function indexValues(array $arrObject){
return trim(Str::lower(collect($arrObject)
->map(function($value){
if(is_array($value)){
return $this->indexValues($value ?? []);
}
return str_replace(array("\r", "\n"), '', strip_tags((string)$value));
})
->values()->join(" ")." ". $this->_file?->originalName ?? ""));
return trim(
Str::lower(
collect($arrObject)
->map(function ($value) {
if (is_array($value)) {
return $this->indexValues($value ?? []);
}
return str_replace(
["\r", "\n"],
"",
strip_tags((string) $value),
);
})
->values()
->join(" ") .
" " .
$this->_file?->originalName ??
"",
),
);
}
public function toDB(): array
@@ -50,10 +57,8 @@ class Record implements JsonSerializable
public static function fromDB(stdClass $data): Record
{
$file = json_decode($data->_file, true);
if (!empty($file)) {
$file = FileData::fromArray($file);
} else {
$file = null;
@@ -69,11 +74,8 @@ class Record implements JsonSerializable
);
}
public function jsonSerialize(): static
{
return $this;
}
}