add child element ordering

This commit is contained in:
Yisroel Baum 2026-06-22 09:51:21 +03:00
parent 7323925319
commit 62dc119b11
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
12 changed files with 554 additions and 11 deletions

View file

@ -38,6 +38,10 @@ interface UpdateElementResponse {
element: Element
}
interface ReorderChildElementsResponse {
childElements: ChildElement[]
}
interface ElementPatchInput {
title?: string
description?: string
@ -145,6 +149,34 @@ export const useElementsStore = defineStore('elements', () => {
}
}
async function reorderChildElements(
parentElementId: string,
childElementIds: number[],
): Promise<boolean> {
childActionError.value = null
isManagingChildren.value = true
try {
const encodedParentElementId = encodeURIComponent(parentElementId)
const response = await fetch(
`${ELEMENTS_URL}/${encodedParentElementId}/children/order`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ childElementIds }),
},
)
return await handleReorderChildElementsResponse(response)
} catch {
childActionError.value = 'Network error - could not save child order'
return false
} finally {
isManagingChildren.value = false
}
}
async function deleteChildElement(
parentElementId: string,
childElementId: number,
@ -350,6 +382,32 @@ export const useElementsStore = defineStore('elements', () => {
return data.element
}
async function handleReorderChildElementsResponse(
response: Response,
): Promise<boolean> {
if (response.status === 401) {
childActionError.value = 'Please log in again'
return false
}
if (response.status === 400 || response.status === 404) {
childActionError.value = await errorMessage(
response,
'Could not save child order',
)
return false
}
if (!response.ok) {
childActionError.value = 'Could not save child order'
return false
}
const data: ReorderChildElementsResponse = await response.json()
childElements.value = data.childElements
return true
}
async function handleDeleteChildElementResponse(
response: Response,
childElementId: number,
@ -406,6 +464,7 @@ export const useElementsStore = defineStore('elements', () => {
fetchElement,
updateElement,
createChildElement,
reorderChildElements,
deleteChildElement,
uploadElementIconImage,
uploadElementShortPdf,

View file

@ -1,12 +1,18 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, reactive, ref, watch } from 'vue'
import {
ChevronDown as ChevronDownIcon,
ChevronUp as ChevronUpIcon,
} from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router'
import ElementSiblingNavigation from '@/components/ElementSiblingNavigation.vue'
import RichTextEditor from '@/components/RichTextEditor.vue'
import SiteHeader from '@/components/SiteHeader.vue'
import { useElementsStore } from '@/stores/elements'
type ChildMoveDirection = 'up' | 'down'
interface ElementForm {
title: string
description: string
@ -168,6 +174,50 @@ async function handleRemoveChild(childElementId: number): Promise<void> {
}
}
async function handleMoveChild(
childElementId: number,
direction: ChildMoveDirection,
): Promise<void> {
if (elementId.value === '') {
return
}
const currentIndex = childElements.value.findIndex((childElement) => {
return childElement.id === childElementId
})
if (currentIndex === -1) {
return
}
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1
if (targetIndex < 0 || targetIndex >= childElements.value.length) {
return
}
const reorderedChildElements = [...childElements.value]
const movingChildElement = reorderedChildElements[currentIndex]
const targetChildElement = reorderedChildElements[targetIndex]
if (movingChildElement === undefined || targetChildElement === undefined) {
return
}
reorderedChildElements[currentIndex] = targetChildElement
reorderedChildElements[targetIndex] = movingChildElement
const childElementIds = reorderedChildElements.map((childElement) => {
return childElement.id
})
childStatus.value = null
const saved = await elementsStore.reorderChildElements(
elementId.value,
childElementIds,
)
if (saved) {
childStatus.value = 'Child order saved'
}
}
function chooseIconImage(): void {
iconImageInput.value?.click()
}
@ -522,7 +572,7 @@ function resetInput(changeEvent: Event): void {
data-cy="admin-child-list"
>
<li
v-for="childElement in childElements"
v-for="(childElement, childIndex) in childElements"
:key="childElement.id"
class="admin-element-page__child-item"
data-cy="admin-child-item"
@ -545,15 +595,44 @@ function resetInput(changeEvent: Event): void {
{{ childElement.description }}
</p>
</div>
<button
class="admin-element-page__secondary-button"
data-cy="admin-remove-child"
type="button"
:disabled="isManagingChildren"
@click="handleRemoveChild(childElement.id)"
>
Remove
</button>
<div class="admin-element-page__child-actions">
<div class="admin-element-page__child-order-actions">
<button
class="admin-element-page__icon-button"
data-cy="admin-move-child-up"
type="button"
:disabled="isManagingChildren || childIndex === 0"
aria-label="Move child up"
title="Move child up"
@click="handleMoveChild(childElement.id, 'up')"
>
<ChevronUpIcon :size="16" aria-hidden="true" />
</button>
<button
class="admin-element-page__icon-button"
data-cy="admin-move-child-down"
type="button"
:disabled="
isManagingChildren ||
childIndex === childElements.length - 1
"
aria-label="Move child down"
title="Move child down"
@click="handleMoveChild(childElement.id, 'down')"
>
<ChevronDownIcon :size="16" aria-hidden="true" />
</button>
</div>
<button
class="admin-element-page__secondary-button"
data-cy="admin-remove-child"
type="button"
:disabled="isManagingChildren"
@click="handleRemoveChild(childElement.id)"
>
Remove
</button>
</div>
</li>
</ul>
<p
@ -778,6 +857,44 @@ function resetInput(changeEvent: Event): void {
line-height: 1.45;
}
.admin-element-page__child-actions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.45rem;
}
.admin-element-page__child-order-actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.admin-element-page__icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text);
background: var(--color-white);
cursor: pointer;
}
.admin-element-page__icon-button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.admin-element-page__icon-button:hover:not(:disabled),
.admin-element-page__icon-button:focus-visible {
border-color: var(--color-slate);
color: var(--color-slate);
}
.admin-element-page__child-add {
display: flex;
align-items: end;
@ -890,5 +1007,9 @@ function resetInput(changeEvent: Event): void {
.admin-element-page__child-item {
flex-direction: column;
}
.admin-element-page__child-actions {
align-self: flex-end;
}
}
</style>