Merge branch 'feature/fold-set-tree'

This commit is contained in:
Yisroel Baum 2026-08-15 22:25:56 +03:00
commit 0b0733cbec
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
3 changed files with 140 additions and 7 deletions

View file

@ -51,7 +51,7 @@ describe('set element layout', () => {
interceptAuthenticatedUser() interceptAuthenticatedUser()
}) })
it('opens a set from the dashboard and shows its full hierarchy', () => { it('opens a set with a foldable element hierarchy', () => {
cy.intercept('GET', '**/api/sets', { cy.intercept('GET', '**/api/sets', {
statusCode: 200, statusCode: 200,
body: { sets: [{ id: 41, name: 'Bible' }] }, body: { sets: [{ id: 41, name: 'Bible' }] },
@ -79,13 +79,35 @@ describe('set element layout', () => {
cy.get('[data-element-id="1"] > .element-node__card') cy.get('[data-element-id="1"] > .element-node__card')
.should('contain.text', 'Genesis') .should('contain.text', 'Genesis')
.and('contain.text', 'book') .and('contain.text', 'book')
.and('have.attr', 'aria-expanded', 'false')
.and('have.attr', 'aria-controls', 'element-children-1')
cy.get('[data-element-id="2"] > .element-node__card').should(
'not.match',
'button',
)
cy.get('[data-element-id="3"]').should('not.exist')
cy.get('[data-element-id="1"] > button.element-node__card').click()
cy.get('[data-element-id="1"] > .element-node__card').should(
'have.attr',
'aria-expanded',
'true',
)
cy.get('[data-element-id="1"] > ol > li').then(($nodes) => { cy.get('[data-element-id="1"] > ol > li').then(($nodes) => {
expect([...$nodes].map((node) => node.dataset.elementId)).to.deep.equal([ expect([...$nodes].map((node) => node.dataset.elementId)).to.deep.equal([
'3', '3',
'5', '5',
]) ])
}) })
cy.get('[data-element-id="3"] > ol > li') cy.get('[data-element-id="4"]').should('not.exist')
cy.get('[data-element-id="3"] > button.element-node__card').click()
cy.get('[data-element-id="3"] > .element-node__card').should(
'have.attr',
'aria-expanded',
'true',
)
cy.get('#element-children-3 > li')
.should('have.length', 1) .should('have.length', 1)
.and('have.attr', 'data-element-id', '4') .and('have.attr', 'data-element-id', '4')
cy.contains('.element-node__card', 'Chapter 1') cy.contains('.element-node__card', 'Chapter 1')
@ -93,6 +115,15 @@ describe('set element layout', () => {
.find('.element-node__kind') .find('.element-node__kind')
.should('have.text', 'Chapter_sections-v2') .should('have.text', 'Chapter_sections-v2')
.and('have.css', 'text-transform', 'none') .and('have.css', 'text-transform', 'none')
cy.get('[data-element-id="1"] > button.element-node__card').click()
cy.get('[data-element-id="3"]').should('not.exist')
cy.get('[data-element-id="1"] > button.element-node__card').click()
cy.get('[data-element-id="4"] > .element-node__card').should(
'contain.text',
'Chapter 1',
)
}) })
it('shows loading and empty layout states', () => { it('shows loading and empty layout states', () => {
@ -183,6 +214,8 @@ describe('set element layout', () => {
cy.wait('@me') cy.wait('@me')
cy.wait('@layout') cy.wait('@layout')
cy.get('[data-element-id="1"] > button.element-node__card').click()
cy.get('[data-element-id="3"] > button.element-node__card').click()
cy.contains('.element-node__card', 'Chapter 1').should('be.visible') cy.contains('.element-node__card', 'Chapter 1').should('be.visible')
cy.document().then((document) => { cy.document().then((document) => {
expect(document.documentElement.scrollWidth).to.be.at.most( expect(document.documentElement.scrollWidth).to.be.at.most(

View file

@ -1,10 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SetElementNode } from '@/stores/setLayout' import type { SetElementNode } from '@/stores/setLayout'
defineProps<{ const props = defineProps<{
nodes: SetElementNode[] nodes: SetElementNode[]
expandedElementIds: ReadonlySet<number>
label?: string label?: string
}>() }>()
defineEmits<{
'toggle-element': [elementId: number]
}>()
function childListId(elementId: number): string {
return `element-children-${elementId}`
}
function isExpanded(elementId: number): boolean {
return props.expandedElementIds.has(elementId)
}
</script> </script>
<template> <template>
@ -14,12 +27,39 @@ defineProps<{
:aria-label="label" :aria-label="label"
> >
<li v-for="node in nodes" :key="node.id" class="element-tree__item" :data-element-id="node.id"> <li v-for="node in nodes" :key="node.id" class="element-tree__item" :data-element-id="node.id">
<div class="element-node__card"> <button
v-if="node.children.length > 0"
type="button"
class="element-node__card element-node__card--toggle"
:aria-expanded="isExpanded(node.id)"
:aria-controls="childListId(node.id)"
@click="$emit('toggle-element', node.id)"
>
<span class="element-node__name">{{ node.name }}</span> <span class="element-node__name">{{ node.name }}</span>
<span class="element-node__kind">{{ node.kind }}</span> <span class="element-node__metadata">
<span class="element-node__kind">{{ node.kind }}</span>
<span
class="element-node__chevron"
:class="{ 'element-node__chevron--expanded': isExpanded(node.id) }"
aria-hidden="true"
></span>
</span>
</button>
<div v-else class="element-node__card">
<span class="element-node__name">{{ node.name }}</span>
<span class="element-node__metadata">
<span class="element-node__kind">{{ node.kind }}</span>
</span>
</div> </div>
<ElementTree v-if="node.children.length > 0" :nodes="node.children" /> <ElementTree
v-if="node.children.length > 0 && isExpanded(node.id)"
:id="childListId(node.id)"
:nodes="node.children"
:expanded-element-ids="expandedElementIds"
@toggle-element="$emit('toggle-element', $event)"
/>
</li> </li>
</ol> </ol>
</template> </template>
@ -69,6 +109,23 @@ defineProps<{
box-shadow: 0 0.55rem 1.5rem rgb(40 62 52 / 6%); box-shadow: 0 0.55rem 1.5rem rgb(40 62 52 / 6%);
} }
.element-node__card--toggle {
width: 100%;
color: inherit;
text-align: left;
cursor: pointer;
}
.element-node__card--toggle:hover {
border-color: rgb(40 92 78 / 34%);
background: #fffdf7;
}
.element-node__card--toggle:focus-visible {
outline: 3px solid rgb(86 127 112 / 34%);
outline-offset: 0.2rem;
}
.element-node__name { .element-node__name {
min-width: 0; min-width: 0;
color: #183029; color: #183029;
@ -78,6 +135,13 @@ defineProps<{
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.element-node__metadata {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.8rem;
}
.element-node__kind { .element-node__kind {
flex: 0 0 auto; flex: 0 0 auto;
padding: 0.35rem 0.55rem; padding: 0.35rem 0.55rem;
@ -90,6 +154,18 @@ defineProps<{
text-transform: none; text-transform: none;
} }
.element-node__chevron {
width: 0.55rem;
height: 0.55rem;
border-right: 2px solid #5e7067;
border-bottom: 2px solid #5e7067;
transform: rotate(-45deg);
}
.element-node__chevron--expanded {
transform: rotate(45deg);
}
@media (max-width: 37.5rem) { @media (max-width: 37.5rem) {
.element-tree { .element-tree {
margin-left: 0.45rem; margin-left: 0.45rem;
@ -106,6 +182,10 @@ defineProps<{
padding: 0.75rem 0.7rem; padding: 0.75rem 0.7rem;
} }
.element-node__metadata {
gap: 0.6rem;
}
.element-node__kind { .element-node__kind {
padding-inline: 0.42rem; padding-inline: 0.42rem;
font-size: 0.56rem; font-size: 0.56rem;

View file

@ -11,6 +11,7 @@ const route = useRoute()
const setLayoutStore = useSetLayoutStore() const setLayoutStore = useSetLayoutStore()
const { layout, loading, error, notFound } = storeToRefs(setLayoutStore) const { layout, loading, error, notFound } = storeToRefs(setLayoutStore)
const currentSetId = ref<number | null>(null) const currentSetId = ref<number | null>(null)
const expandedElementIds = ref(new Set<number>())
const EMPTY_MESSAGE = 'This set does not have any elements yet.' const EMPTY_MESSAGE = 'This set does not have any elements yet.'
watch( watch(
@ -19,6 +20,7 @@ watch(
const rawSetId = Array.isArray(setIdParameter) ? setIdParameter[0] : setIdParameter const rawSetId = Array.isArray(setIdParameter) ? setIdParameter[0] : setIdParameter
const setId = Number(rawSetId) const setId = Number(rawSetId)
currentSetId.value = setId currentSetId.value = setId
expandedElementIds.value = new Set<number>()
await setLayoutStore.fetchSetLayout(setId) await setLayoutStore.fetchSetLayout(setId)
}, },
{ immediate: true }, { immediate: true },
@ -29,8 +31,21 @@ async function retry(): Promise<void> {
return return
} }
expandedElementIds.value = new Set<number>()
await setLayoutStore.fetchSetLayout(currentSetId.value) await setLayoutStore.fetchSetLayout(currentSetId.value)
} }
function toggleElement(elementId: number): void {
const nextExpandedElementIds = new Set(expandedElementIds.value)
if (nextExpandedElementIds.has(elementId)) {
nextExpandedElementIds.delete(elementId)
} else {
nextExpandedElementIds.add(elementId)
}
expandedElementIds.value = nextExpandedElementIds
}
</script> </script>
<template> <template>
@ -81,7 +96,12 @@ async function retry(): Promise<void> {
></p> ></p>
<div v-else class="layout-outline"> <div v-else class="layout-outline">
<ElementTree :nodes="layout.elements" :label="`${layout.set.name} element layout`" /> <ElementTree
:nodes="layout.elements"
:expanded-element-ids="expandedElementIds"
:label="`${layout.set.name} element layout`"
@toggle-element="toggleElement"
/>
</div> </div>
</template> </template>
</section> </section>