Skip to content

Commit

Permalink
Implement adding nodes
Browse files Browse the repository at this point in the history
  • Loading branch information
bkis committed Sep 28, 2023
1 parent d6de6bd commit 4d3d8a9
Show file tree
Hide file tree
Showing 8 changed files with 266 additions and 49 deletions.
1 change: 1 addition & 0 deletions Tekst-API/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,7 @@
"nodes"
],
"summary": "Create node",
"description": "Creates a new node. The position will be automatically set to the last position\nof the node's parent (or the first parent before that has children).",
"operationId": "createNode",
"security": [
{
Expand Down
79 changes: 67 additions & 12 deletions Tekst-API/tekst/routers/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from fastapi import APIRouter, HTTPException, Path, Query, status

from tekst.auth import SuperuserDep
from tekst.logging import log
from tekst.models.text import (
DeleteNodeResult,
MoveNodeRequestBody,
Expand All @@ -30,24 +29,80 @@

@router.post("", response_model=NodeRead, status_code=status.HTTP_201_CREATED)
async def create_node(su: SuperuserDep, node: NodeCreate) -> NodeRead:
"""
Creates a new node. The position will be automatically set to the last position
of the node's parent (or the first parent before that has children).
"""
# find text the node belongs to
if not await TextDocument.find_one(TextDocument.id == node.text_id).exists():
text = await TextDocument.find_one(TextDocument.id == node.text_id)
if not text:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Corresponding text '{node.text_id}' does not exist",
)
# check for semantic duplicates
if await NodeDocument.find_one(
NodeDocument.text_id == node.text_id,
NodeDocument.level == node.level,
NodeDocument.position == node.position,
).exists():
log.warning(f"Cannot create node. Conflict: {node}")
# check if level is valid
if not node.level < len(text.levels):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Conflict with existing node",
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid level {node.level}",
)
# determine node position:
# check if there is a parent
if node.parent_id is None:
# no parent, so make it last node on level 0
# text_id is important in case parent_id == None
last_sibling = (
await NodeDocument.find(
NodeDocument.text_id == text.id,
NodeDocument.parent_id == node.parent_id,
)
.sort(-NodeDocument.position)
.first_or_none()
)
# all fine
if last_sibling:
node.position = last_sibling.position + 1
else:
node.position = 0
else:
# there is a parent, so we need to get the last child of the parent (if any)
# or the one of the previous parent (and so on...) and use its position + 1
parent = await NodeDocument.get(node.parent_id)
while True:
# text_id is important in case parent_id == None
last_child = (
await NodeDocument.find(
NodeDocument.text_id == text.id,
NodeDocument.parent_id == parent.id,
)
.sort(-NodeDocument.position)
.first_or_none()
)
if last_child:
# found a last child of a parent on next higher level
node.position = last_child.position + 1
break
else:
# the parent doesn't have any children, so check the previous one
prev_parent = await NodeDocument.find_one(
NodeDocument.text_id == text.id,
NodeDocument.level == node.level - 1,
NodeDocument.position == parent.position - 1,
)
if not prev_parent:
# the previous parent doesn't exist, so position will be 0
node.position = 0
break
else:
# previous parent exists, so remember it for the next iteration
parent = prev_parent
# increment position of all subsequent nodes on this level
# (including nodes with other parents)
await NodeDocument.find(
NodeDocument.text_id == node.text_id,
NodeDocument.level == node.level,
NodeDocument.position >= node.position,
).inc({NodeDocument.position: 1})
# all fine, create node
return await NodeDocument.model_from(node).create()


Expand Down
26 changes: 0 additions & 26 deletions Tekst-API/tests/integration/test_api_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,32 +115,6 @@ async def test_create_node_invalid_text_fail(
assert resp.status_code == 400, status_fail_msg(400, resp)


@pytest.mark.anyio
async def test_create_node_duplicate_fail(
api_path,
test_client: AsyncClient,
test_data,
insert_test_data,
status_fail_msg,
register_test_user,
get_session_cookie,
):
text_id = await insert_test_data("texts")
endpoint = f"{api_path}/nodes"
node = test_data["nodes"][0]
node["textId"] = text_id

# create superuser
superuser_data = await register_test_user(is_superuser=True)
session_cookie = await get_session_cookie(superuser_data)

resp = await test_client.post(endpoint, json=node, cookies=session_cookie)
assert resp.status_code == 201, status_fail_msg(201, resp)

resp = await test_client.post(endpoint, json=node, cookies=session_cookie)
assert resp.status_code == 409, status_fail_msg(409, resp)


@pytest.mark.anyio
async def test_get_nodes(
api_path, test_client: AsyncClient, test_data, insert_test_data, status_fail_msg
Expand Down
107 changes: 107 additions & 0 deletions Tekst-Web/src/components/admin/AddNodeModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { POST } from '@/api';
import { $t } from '@/i18n';
import type { NodeTreeOption } from '@/views/admin/AdminTextsNodesView.vue';
import { NForm, NFormItem, NModal, NButton, NInput, type InputInst, type FormInst } from 'naive-ui';
import ModalButtonFooter from '@/components/ModalButtonFooter.vue';
import { ref } from 'vue';
import { useFormRules } from '@/formRules';
import { useStateStore } from '@/stores';
import { useMessages } from '@/messages';
const props = withDefaults(defineProps<{ show: boolean; parent: NodeTreeOption | null }>(), {
show: false,
});
const emits = defineEmits(['update:show', 'submit']);
const initialNodeModel = () => ({
label: '',
});
const state = useStateStore();
const { message } = useMessages();
const nodeFormRef = ref<FormInst | null>(null);
const nodeFormModel = ref<Record<string, string | null>>(initialNodeModel());
const { nodeFormRules } = useFormRules();
const loading = ref(false);
const nodeRenameInputRef = ref<InputInst | null>(null);
async function handleSubmit() {
loading.value = true;
nodeFormRef.value
?.validate(async (validationErrors) => {
if (!validationErrors) {
const { data, error } = await POST('/nodes', {
body: {
label: nodeFormModel.value.label || '',
level: (props.parent?.level ?? -1) + 1,
position: Number.MAX_SAFE_INTEGER,
textId: state.text?.id || '',
parentId: props.parent?.key?.toString() || null,
},
});
emits('submit', error ? undefined : data);
emits('update:show', false);
}
})
.catch(() => {
message.error($t('errors.followFormRules'));
});
loading.value = false;
}
</script>

<template>
<n-modal
:show="show"
preset="card"
class="tekst-modal"
size="large"
:bordered="false"
:closable="false"
to="#app-container"
embedded
@update:show="$emit('update:show', $event)"
@after-leave="nodeFormModel.label = ''"
>
<h2>
{{
$t('admin.texts.nodes.add.heading', {
level: (props.parent?.level ?? -1) + 1,
parentLabel: props.parent?.label || state.text?.title || '',
})
}}
</h2>

<n-form
ref="nodeFormRef"
:model="nodeFormModel"
:rules="nodeFormRules"
label-placement="top"
label-width="auto"
require-mark-placement="right-hanging"
>
<n-form-item path="label" :label="$t('models.node.label')">
<n-input
ref="nodeRenameInputRef"
v-model:value="nodeFormModel.label"
type="text"
:disabled="loading"
:autofocus="true"
@keydown.enter="handleSubmit"
/>
</n-form-item>
</n-form>
<ModalButtonFooter>
<n-button secondary :disabled="loading" @click="$emit('update:show', false)">
{{ $t('general.cancelAction') }}
</n-button>
<n-button type="primary" :loading="loading" :disabled="loading" @click="handleSubmit">
{{ $t('general.saveAction') }}
</n-button>
</ModalButtonFooter>
</n-modal>
</template>
25 changes: 18 additions & 7 deletions Tekst-Web/src/components/admin/RenameNodeModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import ModalButtonFooter from '@/components/ModalButtonFooter.vue';
import { ref } from 'vue';
import { useFormRules } from '@/formRules';
import { useModelChanges } from '@/modelChanges';
import { useMessages } from '@/messages';
const props = withDefaults(defineProps<{ show: boolean; node: NodeTreeOption | null }>(), {
show: false,
Expand All @@ -17,6 +18,8 @@ const initialNodeModel = () => ({
label: '',
});
const { message } = useMessages();
const nodeFormRef = ref<FormInst | null>(null);
const nodeFormModel = ref<Record<string, string | null>>(initialNodeModel());
const { changed, getChanges } = useModelChanges(nodeFormModel);
Expand All @@ -28,12 +31,20 @@ const nodeRenameInputRef = ref<InputInst | null>(null);
async function handleSubmit() {
loading.value = true;
const { data, error } = await PATCH('/nodes/{id}', {
params: { path: { id: props.node?.key?.toString() || '' } },
body: getChanges(),
});
emits('submit', error ? undefined : data);
emits('update:show', false);
nodeFormRef.value
?.validate(async (validationErrors) => {
if (!validationErrors) {
const { data, error } = await PATCH('/nodes/{id}', {
params: { path: { id: props.node?.key?.toString() || '' } },
body: getChanges(),
});
emits('submit', error ? undefined : data);
emits('update:show', false);
}
})
.catch(() => {
message.error($t('errors.followFormRules'));
});
loading.value = false;
}
</script>
Expand Down Expand Up @@ -72,7 +83,7 @@ async function handleSubmit() {
ref="nodeRenameInputRef"
v-model:value="nodeFormModel.label"
type="text"
:loading="loading"
:disabled="loading"
:autofocus="true"
@keydown.enter="handleSubmit"
/>
Expand Down
6 changes: 6 additions & 0 deletions Tekst-Web/src/i18n/translations/deDE.yml
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,12 @@ admin:
Textebene "{levelLabel}" löschen möchten?
nodes:
heading: Knoten
add:
heading: Knoten zu "{parentLabel}" auf Ebene {level} hinzufügen
btnAddNodeFirstLevel: Knoten auf oberster Ebene hinzufügen
btnAddNodeFirstLevelTip: Knoten auf oberster Ebene hinzufügen
btnAddChildNodeTip: Kind-Knoten einfügen
msgSuccess: Knoten "{label}" zu "{parentLabel}" hinzugefügt
warnGeneral: |
Das Neuordnen oder Löschen von Textknoten hat gravierende Auswirkungen auf das
Datenmodell des Textes. Es ist dringend empfohlen, ein Backup des aktuellen
Expand Down
6 changes: 6 additions & 0 deletions Tekst-Web/src/i18n/translations/enUS.yml
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,12 @@ admin:
level "{levelLabel}"?
nodes:
heading: Nodes
add:
heading: Add node to "{parentLabel}" on level {level}
btnAddNodeFirstLevel: Add top-level node
btnAddNodeFirstLevelTip: Add node on highest level
btnAddChildNodeTip: Insert child node
msgSuccess: Added node "{label}" to "{parentLabel}"
warnGeneral: |
Rearranging or deleting text nodes has serious impact on the text's data model.
It is recommended to make a backup of the current state of your
Expand Down
Loading

0 comments on commit 4d3d8a9

Please sign in to comment.