edit schema wip

This commit is contained in:
2026-01-08 15:19:08 +02:00
parent ebccac210a
commit 25ad3fefab
18 changed files with 714 additions and 45 deletions
+6
View File
@@ -8,3 +8,9 @@ main {
padding: 20px;
}
}
button.danger {
background-color: #d93526;
border-color: #d93526;
color: white;
}
+36
View File
@@ -0,0 +1,36 @@
<script>
let { onDelete, text } = $props();
let isClicked = $state(false);
function handleTryDelete(e) {
e.preventDefault();
isClicked = true;
}
function handleCancel(e) {
e.preventDefault();
isClicked = false;
}
function handleRealDelete(e) {
e.preventDefault();
onDelete();
}
</script>
{#if !isClicked}
<form onsubmit={handleTryDelete}>
<button class="danger" type="submit">
{@render text()}
</button>
</form>
{/if}
{#if isClicked}
Are you sure?
<form onsubmit={handleCancel}>
<button class="secondary" type="submit">No</button>
</form>
<form onsubmit={handleRealDelete}>
<button class="danger" type="submit">Yes</button>
</form>
{/if}
+150
View File
@@ -0,0 +1,150 @@
<script>
import { arrayMoveElement } from "../helpers";
import { flip } from "svelte/animate";
let {
sortableClass,
onUpdate,
items,
itemView,
itemCssClass,
itemKey = "id",
type = "list",
handleClass = null,
disabled = false,
} = $props();
// let sortableInstance = $state();
let sortableContainer = $state();
let draggedItem = $state();
function handleDragStart(event, item) {
if (disabled) {
return false;
}
draggedItem = item;
// Set data required for drag operation
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", item.id);
// Set a ghost drag image
event.currentTarget.classList.add("dragging");
}
// Handle drag over another item
function handleDragOver(event) {
event.preventDefault();
event.target.closest(".draggable-item").classList.add("dragover");
event.dataTransfer.dropEffect = "move";
}
// Handle dropping the item
function handleDrop(event, targetItem) {
event.preventDefault();
if (draggedItem === targetItem) return;
// Find positions of dragged and target items
const draggedIndex = items.findIndex(
(item) => getItem(item, itemKey) === getItem(draggedItem, itemKey),
);
const targetIndex = items.findIndex(
(item) => getItem(item, itemKey) === getItem(targetItem, itemKey),
);
onUpdate(
arrayMoveElement([...items], draggedIndex, targetIndex),
draggedIndex,
targetIndex,
);
}
function getItem(item, key) {
if (key === null) {
return item;
}
if (key.includes(".")) {
return key.split(".").reduce((a, b) => a[b] ?? null, item);
}
return item[key];
}
function handleDragEnd(event) {
event.target.classList.remove("dragging");
sortableContainer
.querySelector(".dragover")
?.classList.remove("dragover");
draggedItem = null;
const draggableItem = event.target.closest(".draggable-item");
draggableItem.draggable = false;
}
function handleDragLeave(event) {
// event.target.classList.remove("dragover");
const draggableItem = event.target.closest(".draggable-item");
draggableItem.classList.remove("dragover");
draggableItem.draggable = false;
}
function handleMouseDown(e) {
const handleEl = e.target.closest("." + handleClass);
if (!handleEl) {
return true;
}
const draggableItem = e.target.closest(".draggable-item");
draggableItem.draggable = true;
}
function handleMouseUp(e) {
const handleEl = e.target.closest("." + handleClass);
if (!handleEl) {
return true;
}
const draggableItem = e.target.closest(".draggable-item");
draggableItem.draggable = false;
}
</script>
{#if type === "table"}
<tbody
class="sortable-container {sortableClass}"
bind:this={sortableContainer}
>
{#each items as item (getItem(item, itemKey))}
<tr
animate:flip={{ duration: 100 }}
class="draggable-item {itemCssClass}"
draggable="false"
onmousedown={handleMouseDown}
onmouseup={handleMouseUp}
ondragstart={(e) => handleDragStart(e, item)}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={(e) => handleDrop(e, item)}
ondragend={handleDragEnd}
>
{@render itemView(item)}
</tr>
{/each}
</tbody>
{:else}
<div
class="sortable-container {sortableClass}"
bind:this={sortableContainer}
>
{#each items as item (getItem(item, itemKey))}
<div
animate:flip={{ duration: 100 }}
role="listitem"
class="draggable-item {itemCssClass}"
draggable="false"
onmousedown={handleMouseDown}
onmouseup={handleMouseUp}
ondragstart={(e) => handleDragStart(e, item)}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={(e) => handleDrop(e, item)}
ondragend={handleDragEnd}
>
{@render itemView(item)}
</div>
{/each}
</div>
{/if}
@@ -2,14 +2,13 @@
import ChannelLayout from "../../layouts/ChannelLayout.svelte";
import { post } from "../../modules/remote";
import { getApp } from "../../app";
let { channel, user, data } = $props();
let { channel, user, data, newRank } = $props();
let name = $state("");
let alias = $state("");
const app = getApp();
function handleSchemaCreate(e) {
function handleCreate(e) {
e.preventDefault();
console.log(data);
post(
app.url("fields"),
{
@@ -33,7 +32,7 @@
{#snippet body()}
<h3 class="header-small mb-4 mt-5">Create a <em>{data.type}</em> field</h3>
<form onsubmit={handleSchemaCreate}>
<form onsubmit={handleCreate}>
<fieldset>
<label>
Name
@@ -0,0 +1,90 @@
<script>
import ChannelLayout from "../../layouts/ChannelLayout.svelte";
import TextFieldProps from "./TextFieldProps.svelte";
import DeleteButton from "../../common/DeleteButton.svelte";
import { post } from "../../modules/remote";
import { getApp } from "../../app";
let { channel, user, data } = $props();
const app = getApp();
function handleUpdate(e) {
e.preventDefault();
post(
app.url("fields/update"),
{
field: data.field,
},
(data, err) => {
if (err.isEmpty()) {
Turbo.visit(app.url("schemas"));
} else {
console.log(err);
}
},
);
}
function handleDelete() {
post(
app.url("fields/delete"),
{
fieldId: data.field.id,
},
(data, err) => {
if (err.isEmpty()) {
Turbo.visit(app.url("schemas"));
} else {
console.log(err);
}
},
);
}
</script>
<ChannelLayout {body} {channel} {user}></ChannelLayout>
{#snippet body()}
<h3 class="header-small mb-4 mt-5">
Edit <em>{data.field.type}</em> field {data.field.name}
</h3>
<form onsubmit={handleUpdate}>
<fieldset>
<label>
Name
<input
bind:value={data.field.name}
placeholder="ex. Description"
minlength="2"
maxlength="30"
required
/>
</label>
<label>
Alias
<input
bind:value={data.field.alias}
placeholder="ex. description"
minlength="2"
maxlength="30"
required
aria-describedby="alias-helper"
/>
<small id="alias-helper">
Developers will use this to reference the field
</small>
</label>
</fieldset>
{#if data.field.type === "text"}
<TextFieldProps field={data.field}></TextFieldProps>
{/if}
<button type="submit">Update</button>
</form>
<DeleteButton onDelete={handleDelete}>
{#snippet text()}
Delete field
{/snippet}
</DeleteButton>
{/snippet}
@@ -0,0 +1,49 @@
<script>
let { field } = $props();
</script>
<fieldset>
<label>
<input
bind:checked={field.props.required}
type="checkbox"
role="switch"
/>
Required
</label>
<label>
<input
bind:checked={field.props.readonly}
type="checkbox"
role="switch"
/>
Readonly
</label>
<label>
<input
bind:checked={field.props.hidden}
type="checkbox"
role="switch"
/>
Hidden
</label>
</fieldset>
<fieldset>
<label>
Default
<input bind:value={field.props.default} />
</label>
<label>
Help text
<input bind:value={field.props.help} />
</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>
@@ -0,0 +1,61 @@
<script>
import ChannelLayout from "../../layouts/ChannelLayout.svelte";
import Sortable from "../../common/Sortable.svelte";
import { post } from "../../modules/remote";
import { getApp } from "../../app";
let { channel, user, data } = $props();
const app = getApp();
function handleSchemaCreate(e) {
e.preventDefault();
// post(
// channel.lucentUrl + "/schemas",
// {
// name: newSchemaName,
// alias: newSchemaAlias,
// },
// (data, err) => {
// if (err.isEmpty()) {
// Turbo.visit(window.location.href);
// }
// },
// );
}
</script>
<ChannelLayout {body} {channel} {user}></ChannelLayout>
{#snippet body()}
<h3>Edit Schema</h3>
<form onsubmit={handleSchemaCreate}>
<fieldset>
<label>
Name
<input
bind:value={data.schema.name}
placeholder="ex. Blog Posts"
minlength="2"
maxlength="30"
required
/>
<small id="alias-helper">Plural is recommended</small>
</label>
<label>
Alias
<input
bind:value={data.schema.alias}
placeholder="ex. blog_posts"
minlength="2"
maxlength="30"
required
aria-describedby="alias-helper"
/>
<small id="alias-helper">
Developers will use this to reference the field
</small>
</label>
</fieldset>
<button type="submit">Update</button>
</form>
{/snippet}
+39 -6
View File
@@ -1,10 +1,12 @@
<script>
import ChannelLayout from "../../layouts/ChannelLayout.svelte";
import Sortable from "../../common/Sortable.svelte";
import { post } from "../../modules/remote";
import { getApp } from "../../app";
let { channel, user, data } = $props();
let newSchemaName = $state("");
let newSchemaAlias = $state("");
let fields = $state(data.fields);
const app = getApp();
function handleSchemaCreate(e) {
@@ -22,6 +24,20 @@
},
);
}
function handleSortUpdate(updatedFields) {
let updatedFieldIds = updatedFields.map((f) => f.id);
fields = fields.filter((f) => !updatedFieldIds.includes(f.id));
fields = [...fields, ...updatedFields];
post(
channel.lucentUrl + "/fields/reorder",
{
ids: updatedFieldIds,
},
(data, err) => {},
);
}
</script>
<ChannelLayout {body} {channel} {user}></ChannelLayout>
@@ -65,15 +81,32 @@
<div style="display: flex;gap:20px">
{#each data.schemas as schema}
<article style="min-width: 300px;">
<header>{schema.name}</header>
<header>
<a href={app.url("schemas/edit/" + schema.id)}
>{schema.name}</a
>
</header>
<details>
<summary>Fields</summary>
<table>
<tbody>
<tr>
<td>Title</td>
</tr>
</tbody>
<Sortable
type="table"
onUpdate={handleSortUpdate}
items={fields.filter(
(field) => field.schemaId === schema.id,
)}
>
{#snippet itemView(field)}
<td
><a
href={app.url(
"fields/edit/" + field.id,
)}>{field.name}</a
></td
>
<td>{field.type}</td>
{/snippet}
</Sortable>
</table>
</details>
<details>
+103 -29
View File
@@ -1,52 +1,126 @@
import {formatDistanceToNow, parseJSON, format, parse} from "date-fns";
import { formatDistanceToNow, parseJSON, format, parse } from "date-fns";
export function friendlyDate(date) {
return formatDistanceToNow(parseJSON(date), {addSuffix: true});
return formatDistanceToNow(parseJSON(date), { addSuffix: true });
}
export function readableDate(date) {
if(!date){
return "";
}
return format(parseJSON(date), "dd MMM yyyy");
if (!date) {
return "";
}
return format(parseJSON(date), "dd MMM yyyy");
}
export function readableDatetime(date) {
if(!date){
return "";
}
if (!date) {
return "";
}
return format(parseJSON(date), "dd MMM yyyy HH:mm");
return format(parseJSON(date), "dd MMM yyyy HH:mm");
}
export function stripHtml(html = "") {
let tmp = document.createElement("div");
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || "";
let tmp = document.createElement("div");
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || "";
}
export function randomId(length = 10) {
return Math.random().toString(36).substring(2, length + 2);
return Math.random()
.toString(36)
.substring(2, length + 2);
}
export function clickOutside(node) {
const handleClick = event => {
if (node && !node.contains(event.target) && !event.defaultPrevented) {
node.dispatchEvent(
new CustomEvent('click_outside', node)
)
}
const handleClick = (event) => {
if (node && !node.contains(event.target) && !event.defaultPrevented) {
node.dispatchEvent(new CustomEvent("click_outside", node));
}
};
document.addEventListener('click', handleClick, true);
document.addEventListener("click", handleClick, true);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
}
}
return {
destroy() {
document.removeEventListener("click", handleClick, true);
},
};
}
export function arrayUnique(array) {
return array.filter((value, index) => array.indexOf(value) === index);
}
export function arrayUniqueBy(items, uniqueBy) {
const ids = new Set(items.map((item) => item[uniqueBy]));
return [...ids].map((id) => items.find((i) => i[uniqueBy] === id));
}
export function arrayMoveElement(array, from, to) {
if (from === to) {
return array;
}
const item = array.find((v, i) => i === from);
const arrayWithout = array.filter((v, i) => i !== from);
if (from > to) {
return arrayWithout.reduce((c, v, i) => {
if (i === to) {
return [...c, item, v];
}
return [...c, v];
}, []);
}
return arrayWithout.reduce((c, v, i) => {
if (i + 1 === to) {
return [...c, v, item];
}
return [...c, v];
}, []);
}
export function isEqual(db, ed) {
let isObject = (x) =>
typeof x === "object" && !Array.isArray(x) && x !== null;
let isArray = (x) => x?.constructor === Array;
let isEmpty = (x) => x === null || x === undefined || x == [];
const db_value = db ?? null;
const ed_value = ed ?? null;
if (isObject(db_value)) {
let keys = Object.keys(db_value);
return keys.reduce((acc, k) => {
if (acc === false) {
return false;
}
return isEqual(db_value?.[k], ed_value?.[k]);
}, true);
}
if (isArray(db_value)) {
return db_value.reduce((c, v, i) => {
if (c === false) {
return false;
}
return isEqual(v, ed_value?.[i]);
}, true);
}
if (isEmpty(db_value) && isEmpty(ed_value)) {
return true;
}
if (db_value == ed_value) {
return true;
}
return false;
// const ok = Object.keys,
// tx = typeof x,
// ty = typeof y;
// return x && y && tx === "object" && tx === ty
// ? ok(x).length === ok(y).length &&
// ok(x).every((key) => isEqual(x[key], y[key]))
// : x === y;
}
+4
View File
@@ -14,6 +14,8 @@ import ContentIndex from "./svelte/content/Index.svelte";
import HomeEntry from "./entry/HomeEntry/HomeEntry.svelte";
import SchemaEntry from "./entry/SchemaEntry/SchemaEntry.svelte";
import FieldCreateEntry from "./entry/FieldCreateEntry/FieldCreateEntry.svelte";
import FieldEditEntry from "./entry/FieldEditEntry/FieldEditEntry.svelte";
import SchemaEditEntry from "./entry/SchemaEditEntry/SchemaEditEntry.svelte";
import BuildReport from "./svelte/build/Report.svelte";
import { createApp } from "./app";
@@ -31,6 +33,8 @@ const entryComponents = {
setup: SetupIndex,
schemas: SchemaEntry,
fieldCreate: FieldCreateEntry,
fieldEdit: FieldEditEntry,
schemaEdit: SchemaEditEntry,
};
Turbo.start();
+24 -3
View File
@@ -10,19 +10,40 @@ class FieldRepo
const TABLE_NAME = "fields";
/**
* @@return Field[]
* @return Field[]
*/
public static function all(): array
{
return DB::table(self::TABLE_NAME)
->orderBy("name")
->orderBy("rank")
->get()
->map(SchemaModule::fromDb(...))
->map(FieldModule::fromDb(...))
->toArray();
}
public static function findOne(string $id): ?Field
{
return DB::table(self::TABLE_NAME)
->where("id", $id)
->get()
->map(FieldModule::fromDb(...))
->first();
}
public static function insert(Field $field): void
{
DB::table(self::TABLE_NAME)->insert(FieldModule::toDb($field));
}
public static function update(Field $field): void
{
DB::table(self::TABLE_NAME)
->where("id", $field->id)
->update(FieldModule::toDb($field));
}
public static function delete(string $fieldId): void
{
DB::table(self::TABLE_NAME)->where("id", $fieldId)->delete();
}
}
+9
View File
@@ -20,6 +20,15 @@ class SchemaRepo
->toArray();
}
public static function findOne(string $id): ?Schema
{
return DB::table(self::TABLE_NAME)
->where("id", $id)
->get()
->map(SchemaModule::fromDb(...))
->first();
}
public static function insert(Schema $schema): void
{
DB::table(self::TABLE_NAME)->insert(SchemaModule::toDb($schema));
+1
View File
@@ -10,6 +10,7 @@ class Field
public string $name,
public string $type,
public string $schemaId,
public int $rank,
public IFieldProp $props,
) {}
}
+7 -2
View File
@@ -18,10 +18,15 @@ class FieldProp
};
}
public static function fromDb(string $propsJson): IFieldProp
public static function fromDb(string $type, string $propsJson): IFieldProp
{
$props = json_decode($propsJson, true);
return match ($props["type"]) {
return self::fromArray($type, $props);
}
public static function fromArray(string $type, array $props): IFieldProp
{
return match ($type) {
"text" => new TextFieldProp(
required: $props["required"] ?? false,
readonly: $props["readonly"] ?? false,
+6 -1
View File
@@ -23,6 +23,7 @@ class FieldModule
"alias" => $field->alias,
"name" => $field->name,
"type" => $field->type,
"rank" => $field->rank,
"props" => json_encode($field->props),
"schema_id" => $field->schemaId,
];
@@ -36,7 +37,11 @@ class FieldModule
name: data_get($data, "name"),
type: data_get($data, "type"),
schemaId: data_get($data, "schema_id"),
props: FieldProp::fromDb(data_get($data, "props")),
rank: data_get($data, "rank"),
props: FieldProp::fromDb(
data_get($data, "type"),
data_get($data, "props"),
),
);
}
}
+91
View File
@@ -54,6 +54,8 @@ class FieldController extends Controller
$name = $request->input("name");
$alias = $request->input("alias");
$fieldType = $request->input("fieldType");
$fields = FieldRepo::all();
$newRank = collect($fields)->last()->rank + 1;
$validator = Validator::make($request->all(), [
"name" => "required|string|max:30|min:2",
@@ -77,9 +79,98 @@ class FieldController extends Controller
alias: $alias,
type: $fieldType,
props: $fieldProps,
rank: $newRank,
);
FieldRepo::insert($field);
return response()->json(["field" => $field], 201);
}
public function edit(Request $request)
{
$fieldId = $request->route("id");
$fields = FieldRepo::all();
$field = collect($fields)->firstWhere("id", $fieldId);
if (empty($field)) {
return response()->json(["errors" => ["Field not found"]], 404);
}
$schemas = SchemaRepo::all();
$schema = collect($schemas)->firstWhere("id", $field->schemaId);
return $this->svelte->render(
view: "fieldEdit",
title: "Edit Field",
data: [
"schemas" => $schemas,
"schema" => $schema,
"field" => $field,
],
);
}
public function postUpdate(Request $request)
{
$fieldData = $request->input("field");
$validator = Validator::make($request->all(), [
"field.name" => "required|string|max:30|min:2",
"field.alias" => "required|alpha_dash:ascii|max:30|min:2",
]);
if ($validator->fails()) {
return response()->json(["errors" => $validator->errors()], 422);
}
$field = FieldRepo::findOne(data_get($fieldData, "id"));
if (empty($field)) {
return response()->json(["errors" => ["Field not found"]], 404);
}
$fieldProps = FieldProp::fromArray(
$field->type,
data_get($fieldData, "props"),
);
$field = new Field(
id: $field->id,
schemaId: $field->schemaId,
name: data_get($fieldData, "name"),
alias: data_get($fieldData, "alias"),
type: $field->type,
rank: $field->rank,
props: $fieldProps,
);
FieldRepo::update($field);
return response()->json(["field" => $field], 200);
}
public function postDelete(Request $request)
{
$fieldId = $request->input("fieldId");
$field = FieldRepo::findOne($fieldId);
if (empty($field)) {
return response()->json(["errors" => ["Field not found"]], 404);
}
FieldRepo::delete($fieldId);
return response()->json([], 200);
}
public function postReorder(Request $request)
{
$ids = $request->input("ids");
foreach ($ids as $index => $id) {
$field = FieldRepo::findOne($id);
if ($field) {
$field->rank = $index;
FieldRepo::update($field);
}
}
return response()->json(["ids" => $ids], 200);
}
}
+21
View File
@@ -7,6 +7,7 @@ use Illuminate\Http\Request;
use Lucent\Account\AccountService;
use Lucent\Account\AuthService;
use Lucent\Core\Repository\SchemaRepo;
use Lucent\Core\Repository\FieldRepo;
use Lucent\Core\Schema\Data\Schema;
use Lucent\Id\Id;
use Lucent\Svelte\Svelte;
@@ -23,12 +24,14 @@ class SchemaController extends Controller
public function home()
{
$schemas = SchemaRepo::all();
$fields = FieldRepo::all();
return $this->svelte->render(
view: "schemas",
title: "Schemas",
data: [
"schemas" => $schemas,
"fields" => $fields,
],
);
}
@@ -58,4 +61,22 @@ class SchemaController extends Controller
201,
);
}
public function edit(Request $request)
{
$id = $request->route("id");
$schema = SchemaRepo::findOne($id);
if (empty($schema)) {
return response()->json(["errors" => ["Schema not found"]], 404);
}
return $this->svelte->render(
view: "schemaEdit",
title: "Edit Schema",
data: [
"schema" => $schema,
],
);
}
}
+14
View File
@@ -61,9 +61,23 @@ Route::group(
"build",
]);
Route::get("/schemas", [SchemaController::class, "home"]);
Route::get("/schemas/edit/{id}", [SchemaController::class, "edit"]);
Route::post("/schemas", [SchemaController::class, "postCreate"]);
Route::get("/fields/create", [FieldController::class, "create"]);
Route::get("/fields/edit/{id}", [FieldController::class, "edit"]);
Route::post("/fields", [FieldController::class, "postCreate"]);
Route::post("/fields/update", [
FieldController::class,
"postUpdate",
]);
Route::post("/fields/delete", [
FieldController::class,
"postDelete",
]);
Route::post("/fields/reorder", [
FieldController::class,
"postReorder",
]);
});
Route::middleware(["lucent.auth"])->group(function () {