add child creation

This commit is contained in:
Yisroel Baum 2026-06-21 22:02:36 +03:00
parent 7c84eefe78
commit 34b0fd0b44
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
7 changed files with 332 additions and 2 deletions

View file

@ -3,6 +3,8 @@
namespace App\Controllers; namespace App\Controllers;
use App\Element\Element; use App\Element\Element;
use App\Element\UseCases\CreateChildElement\CreateChildElement;
use App\Element\UseCases\CreateChildElement\CreateChildElementRequest;
use App\Element\UseCases\GetElement\GetElement; use App\Element\UseCases\GetElement\GetElement;
use App\Element\UseCases\GetElement\GetElementRequest; use App\Element\UseCases\GetElement\GetElementRequest;
use App\Element\UseCases\UpdateElement\UpdateElement; use App\Element\UseCases\UpdateElement\UpdateElement;
@ -18,6 +20,7 @@ class ElementController
{ {
public function __construct( public function __construct(
private GetElement $getElement, private GetElement $getElement,
private CreateChildElement $createChildElement,
private UpdateElement $updateElement, private UpdateElement $updateElement,
private FileUploader $fileUploader, private FileUploader $fileUploader,
) { ) {
@ -55,6 +58,30 @@ class ElementController
], 200); ], 200);
} }
public function createChild(Request $request, ?int $parentId): JsonResponse
{
try {
$element = $this->createChildElement->execute(
new CreateChildElementRequest(
parentElementId: $parentId,
title: $this->stringInput($request, 'title'),
)
);
} catch (BadRequestException $exception) {
return new JsonResponse([
'error' => $exception->getMessage(),
], 400);
} catch (NotFoundException $exception) {
return new JsonResponse([
'error' => $exception->getMessage(),
], 404);
}
return new JsonResponse([
'element' => $this->buildElementPayload($element),
], 201);
}
public function update(Request $request): JsonResponse public function update(Request $request): JsonResponse
{ {
$file = $this->uploadedFileInput($request, 'file'); $file = $this->uploadedFileInput($request, 'file');

View file

@ -0,0 +1,50 @@
<?php
namespace App\Element\UseCases\CreateChildElement;
use App\Element\CreateElementDto;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
class CreateChildElement
{
public function __construct(private ElementRepository $elementRepository)
{
}
/**
* @throws BadRequestException
* @throws NotFoundException
*/
public function execute(CreateChildElementRequest $request): Element
{
if ($request->parentElementId === null) {
throw new BadRequestException('parentElementId is required');
}
if ($request->title === null || $request->title === '') {
throw new BadRequestException('title is required');
}
$parentElement = $this->elementRepository->find(
$request->parentElementId
);
if ($parentElement === null) {
throw new NotFoundException('Parent element not found');
}
return $this->elementRepository->create(new CreateElementDto(
set: $parentElement->getSet(),
title: $request->title,
description: '',
iconImageUrl: null,
richText: '',
shortPdfPath: null,
longPdfPath: null,
youtubeUrl: null,
parentElement: $parentElement,
));
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Element\UseCases\CreateChildElement;
class CreateChildElementRequest
{
public function __construct(
public ?int $parentElementId,
public ?string $title,
) {
}
}

View file

@ -12,5 +12,10 @@ Route::get('/me', [AuthController::class, 'me'])
->middleware(AuthMiddleware::class); ->middleware(AuthMiddleware::class);
Route::get('/sets', [SetController::class, 'index']); Route::get('/sets', [SetController::class, 'index']);
Route::get('/elements/{id}', [ElementController::class, 'show']); Route::get('/elements/{id}', [ElementController::class, 'show']);
Route::post('/elements/{parentId}/children', [
ElementController::class,
'createChild',
])
->middleware(AuthMiddleware::class);
Route::post('/element/update', [ElementController::class, 'update']) Route::post('/element/update', [ElementController::class, 'update'])
->middleware(AuthMiddleware::class); ->middleware(AuthMiddleware::class);

View file

@ -5,6 +5,7 @@ namespace Tests\Unit\Controllers;
use App\Controllers\ElementController; use App\Controllers\ElementController;
use App\Element\CreateElementDto; use App\Element\CreateElementDto;
use App\Element\Element; use App\Element\Element;
use App\Element\UseCases\CreateChildElement\CreateChildElement;
use App\Element\UseCases\GetElement\GetElement; use App\Element\UseCases\GetElement\GetElement;
use App\Element\UseCases\UpdateElement\UpdateDescription; use App\Element\UseCases\UpdateElement\UpdateDescription;
use App\Element\UseCases\UpdateElement\UpdateElement; use App\Element\UseCases\UpdateElement\UpdateElement;
@ -61,6 +62,7 @@ class ElementControllerTest extends TestCase
); );
$this->controller = new ElementController( $this->controller = new ElementController(
$getElement, $getElement,
new CreateChildElement($this->elementRepo),
$updateElement, $updateElement,
$this->fileUploader, $this->fileUploader,
); );

View file

@ -29,6 +29,10 @@ export interface UpdateElementInput {
youtubeUrl: string youtubeUrl: string
} }
export interface CreateChildElementInput {
title: string
}
interface UpdateElementResponse { interface UpdateElementResponse {
element: Element element: Element
} }
@ -48,6 +52,7 @@ interface ErrorResponse {
} }
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string
const ELEMENTS_URL = `${API_BASE_URL}/api/elements`
const ELEMENT_UPDATE_URL = `${API_BASE_URL}/api/element/update` const ELEMENT_UPDATE_URL = `${API_BASE_URL}/api/element/update`
export const useElementsStore = defineStore('elements', () => { export const useElementsStore = defineStore('elements', () => {
@ -59,7 +64,9 @@ export const useElementsStore = defineStore('elements', () => {
const isUploadingIconImage = ref(false) const isUploadingIconImage = ref(false)
const isUploadingShortPdf = ref(false) const isUploadingShortPdf = ref(false)
const isUploadingLongPdf = ref(false) const isUploadingLongPdf = ref(false)
const isManagingChildren = ref(false)
const saveError = ref<string | null>(null) const saveError = ref<string | null>(null)
const childActionError = ref<string | null>(null)
async function fetchElement(elementId: string): Promise<void> { async function fetchElement(elementId: string): Promise<void> {
element.value = null element.value = null
@ -101,6 +108,37 @@ export const useElementsStore = defineStore('elements', () => {
return await saveElementPatch(elementId, input, 'Could not save element') return await saveElementPatch(elementId, input, 'Could not save element')
} }
async function createChildElement(
parentElementId: string,
input: CreateChildElementInput,
): Promise<Element | null> {
childActionError.value = null
isManagingChildren.value = true
try {
const encodedParentElementId = encodeURIComponent(parentElementId)
const response = await fetch(
`${ELEMENTS_URL}/${encodedParentElementId}/children`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(input),
},
)
return await handleChildElementResponse(
response,
'Could not add child element',
)
} catch {
childActionError.value = 'Network error - could not add child element'
return null
} finally {
isManagingChildren.value = false
}
}
async function clearElementIconImage(elementId: string): Promise<boolean> { async function clearElementIconImage(elementId: string): Promise<boolean> {
return await saveElementPatch( return await saveElementPatch(
elementId, elementId,
@ -253,6 +291,29 @@ export const useElementsStore = defineStore('elements', () => {
return true return true
} }
async function handleChildElementResponse(
response: Response,
failureMessage: string,
): Promise<Element | null> {
if (response.status === 401) {
childActionError.value = 'Please log in again'
return null
}
if (response.status === 400 || response.status === 404) {
childActionError.value = await errorMessage(response, failureMessage)
return null
}
if (!response.ok) {
childActionError.value = failureMessage
return null
}
const data: UpdateElementResponse = await response.json()
return data.element
}
async function errorMessage( async function errorMessage(
response: Response, response: Response,
fallbackMessage: string, fallbackMessage: string,
@ -274,9 +335,12 @@ export const useElementsStore = defineStore('elements', () => {
isUploadingIconImage, isUploadingIconImage,
isUploadingShortPdf, isUploadingShortPdf,
isUploadingLongPdf, isUploadingLongPdf,
isManagingChildren,
saveError, saveError,
childActionError,
fetchElement, fetchElement,
updateElement, updateElement,
createChildElement,
uploadElementIconImage, uploadElementIconImage,
uploadElementShortPdf, uploadElementShortPdf,
uploadElementLongPdf, uploadElementLongPdf,

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import RichTextEditor from '@/components/RichTextEditor.vue' import RichTextEditor from '@/components/RichTextEditor.vue'
import SiteHeader from '@/components/SiteHeader.vue' import SiteHeader from '@/components/SiteHeader.vue'
import { useElementsStore } from '@/stores/elements' import { useElementsStore } from '@/stores/elements'
@ -13,19 +13,28 @@ interface ElementForm {
youtubeUrl: string youtubeUrl: string
} }
interface ChildElementForm {
title: string
}
const route = useRoute() const route = useRoute()
const router = useRouter()
const elementsStore = useElementsStore() const elementsStore = useElementsStore()
const { const {
element, element,
childElements,
isLoading, isLoading,
error, error,
isSaving, isSaving,
isUploadingIconImage, isUploadingIconImage,
isUploadingShortPdf, isUploadingShortPdf,
isUploadingLongPdf, isUploadingLongPdf,
isManagingChildren,
saveError, saveError,
childActionError,
} = storeToRefs(elementsStore) } = storeToRefs(elementsStore)
const savedMessage = ref<string | null>(null) const savedMessage = ref<string | null>(null)
const childStatus = ref<string | null>(null)
const iconImageStatus = ref<string | null>(null) const iconImageStatus = ref<string | null>(null)
const shortPdfStatus = ref<string | null>(null) const shortPdfStatus = ref<string | null>(null)
const longPdfStatus = ref<string | null>(null) const longPdfStatus = ref<string | null>(null)
@ -40,6 +49,10 @@ const form = reactive<ElementForm>({
youtubeUrl: '', youtubeUrl: '',
}) })
const childForm = reactive<ChildElementForm>({
title: '',
})
const elementId = computed(() => { const elementId = computed(() => {
const routeElementId = route.params.id const routeElementId = route.params.id
@ -58,6 +71,10 @@ const statusMessage = computed(() => {
return saveError.value ?? savedMessage.value return saveError.value ?? savedMessage.value
}) })
const childStatusMessage = computed(() => {
return childActionError.value ?? childStatus.value
})
watch( watch(
elementId, elementId,
(currentElementId) => { (currentElementId) => {
@ -66,6 +83,8 @@ watch(
} }
savedMessage.value = null savedMessage.value = null
childStatus.value = null
childForm.title = ''
iconImageStatus.value = null iconImageStatus.value = null
shortPdfStatus.value = null shortPdfStatus.value = null
longPdfStatus.value = null longPdfStatus.value = null
@ -103,6 +122,27 @@ async function handleSubmit(): Promise<void> {
} }
} }
async function handleAddChild(): Promise<void> {
if (elementId.value === '' || childForm.title === '') {
return
}
childStatus.value = null
const childElement = await elementsStore.createChildElement(elementId.value, {
title: childForm.title,
})
if (childElement === null) {
return
}
childForm.title = ''
await router.push({
name: 'admin-element',
params: { id: childElement.id.toString() },
})
}
function chooseIconImage(): void { function chooseIconImage(): void {
iconImageInput.value?.click() iconImageInput.value?.click()
} }
@ -449,6 +489,78 @@ function resetInput(changeEvent: Event): void {
/> />
</label> </label>
<section class="admin-element-page__children">
<span class="admin-element-page__label">Child Elements</span>
<ul
v-if="childElements.length > 0"
class="admin-element-page__child-list"
data-cy="admin-child-list"
>
<li
v-for="childElement in childElements"
:key="childElement.id"
class="admin-element-page__child-item"
>
<RouterLink
:to="{
name: 'admin-element',
params: { id: childElement.id },
}"
class="admin-element-page__child-link"
data-cy="admin-child-edit-link"
>
{{ childElement.title }}
</RouterLink>
<p
v-if="childElement.description !== ''"
class="admin-element-page__child-description"
>
{{ childElement.description }}
</p>
</li>
</ul>
<p
v-else
class="admin-element-page__empty-media"
data-cy="admin-child-empty"
>
No child elements
</p>
<div class="admin-element-page__child-add">
<label class="admin-element-page__child-title-field">
<span class="admin-element-page__label">New child title</span>
<input
v-model="childForm.title"
class="admin-element-page__input"
data-cy="admin-child-title"
type="text"
:disabled="isManagingChildren"
@keydown.enter.prevent="handleAddChild"
/>
</label>
<button
class="admin-element-page__secondary-button"
data-cy="admin-add-child"
type="button"
:disabled="isManagingChildren || childForm.title === ''"
@click="handleAddChild"
>
{{ isManagingChildren ? 'Adding...' : 'Add child' }}
</button>
</div>
<p
v-if="childStatusMessage !== null"
:class="[
'admin-element-page__status',
'admin-element-page__status--inline',
]"
data-cy="admin-child-status"
aria-live="polite"
>
{{ childStatusMessage }}
</p>
</section>
<div class="admin-element-page__actions"> <div class="admin-element-page__actions">
<button <button
class="admin-element-page__save" class="admin-element-page__save"
@ -516,7 +628,8 @@ function resetInput(changeEvent: Event): void {
gap: 0.35rem; gap: 0.35rem;
} }
.admin-element-page__media-field { .admin-element-page__media-field,
.admin-element-page__children {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@ -575,6 +688,58 @@ function resetInput(changeEvent: Event): void {
gap: 0.7rem; gap: 0.7rem;
} }
.admin-element-page__child-list {
display: flex;
flex-direction: column;
gap: 0.65rem;
width: 100%;
padding: 0;
margin: 0;
list-style: none;
}
.admin-element-page__child-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 4px;
}
.admin-element-page__child-link {
color: var(--color-slate);
font-size: 0.95rem;
font-weight: 600;
}
.admin-element-page__child-link:hover,
.admin-element-page__child-link:focus-visible {
color: var(--color-text);
}
.admin-element-page__child-description {
margin: 0;
color: var(--color-text-muted);
font-size: 0.9rem;
line-height: 1.45;
}
.admin-element-page__child-add {
display: flex;
align-items: end;
gap: 0.7rem;
width: 100%;
}
.admin-element-page__child-title-field {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.admin-element-page__file-input { .admin-element-page__file-input {
display: none; display: none;
} }
@ -663,5 +828,10 @@ function resetInput(changeEvent: Event): void {
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
} }
.admin-element-page__child-add {
align-items: stretch;
flex-direction: column;
}
} }
</style> </style>