Skip to content
73 changes: 73 additions & 0 deletions playground/VIEW_CONTROLS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# View Controls (Headless Pattern)

This playground note documents a **UI-agnostic** pattern for view filtering, sorting and pagination.

## Goal

Share logic in the module (query building, refresh, matching a target view block) while letting each downstream app choose its own UI.

## Recommended shared API

Create a composable in module/runtime such as `useDrupalCeViewControls()` that exposes:

- `rows`
- `pager`
- `filters`
- `sorts`
- `isLoading`
- `error`
- `noResults`
- `setFilter(key, value)`
- `setSort(key, value)`
- `setPage(page)`
- `reset()`
- `refresh()`

## Important matching rule

When a page contains multiple view blocks, always scope the refreshed result by:

- `viewId`
- `displayId`
- `parentUuid` (when present)

This prevents one block from reading another block's data.

## Query conventions

- Arrays should serialize as repeated `key[]` entries.
- Normalize sort order to `ASC` / `DESC` for Drupal Views compatibility.

## Runtime behavior

- Debounce UI-driven refreshes.
- Abort in-flight requests when a newer request starts.
- Treat missing view node after refresh as potential empty state (if backend omits block on no rows).

## Notes for downstream themes/apps

- Keep module composable headless.
- Implement UI in the app/theme layer (Nuxt UI, custom CSS, etc.).

See `playground/composables/useDrupalCeViewControls.example.ts` for a minimal shape.

## Lupus Decoupled Views compatibility

This pattern is intentionally aligned with:

- `lupus_decoupled/modules/lupus_decoupled_views`

Expected integration contract:

- exposed filters via URL query params
- exposed sorts (`sort_by`, `sort_order`)
- pager data (`current`, `totalPages`)
- optional no-results message (`noResults` or `no_results`)
- block scoping via `viewId`, `displayId`, `parentUuid`

Known constraints:

- payload details depend on Drupal/Lupus backend version and processor behavior
- multiple view blocks on one page must be scoped correctly (`viewId` + `displayId` + `parentUuid`)
- shared/overlapping exposed filter identifiers across blocks can conflict at query-string level
- backend memory/cache issues (e.g. APCu/PHP limits) must be solved in Drupal infrastructure
158 changes: 110 additions & 48 deletions playground/components/Drupal/DrupalViewsPagination.vue
Original file line number Diff line number Diff line change
@@ -1,64 +1,126 @@
<template>
<div class="views-pager">
<nav
class="isolate inline-flex -space-x-px gap-1"
aria-label="Pagination"
>
<a
v-if="previousURL"
:href="previousURL"
class="relative inline-flex items-center px-2 py-2 text-sm min-w-10"
<nav class="isolate inline-flex -space-x-px gap-1" aria-label="Pagination">
<NuxtLink
v-if="hasPrevious"
class="relative inline-flex min-w-10 items-center px-2 py-2 text-sm"
:to="toForPage(currentPage - 1)"
@click="goTo(currentPage - 1)"
>
<span class="sr-only">Previous</span>
&lt;&lt;
</a>
</NuxtLink>

<span
v-if="hellipLeft"
class="relative inline-flex items-center px-4 py-2 min-w-10"
>&hellip;</span>
<template v-for="n in totalPages">
<component
:is="n-1 == current ? 'span' : 'a'"
v-if="n-1 == current || (n-1 < current && n-1 > current - (maxLinks/2)-1) || (n-1 > current && n-1 < current + (maxLinks/2)+1)"
:key="n"
:href="'?page=' + (n-1)"
:class="{
'relative z-10 inline-flex items-center px-4 py-2 min-w-10': n-1 == current,
'relative inline-flex items-center px-4 py-2 min-w-10': n - 1 != current,
}"
>
{{ n }}
</component>
</template>
v-if="showLeftEllipsis"
class="relative inline-flex min-w-10 items-center px-4 py-2"
>
&hellip;
</span>

<component
:is="page.index === currentPage ? 'span' : 'NuxtLink'"
v-for="page in pageLinks"
:key="page.index"
:class="{
'relative z-10 inline-flex min-w-10 items-center px-4 py-2': page.index === currentPage,
'relative inline-flex min-w-10 items-center px-4 py-2': page.index !== currentPage,
}"
:to="page.index === currentPage ? undefined : toForPage(page.index)"
@click="page.index === currentPage ? undefined : goTo(page.index)"
>
{{ page.label }}
</component>

<span
v-if="hellipRight"
class="relative inline-flex items-center min-w-10"
>&hellip;</span>
<a
v-if="nextURL"
:href="nextURL"
class="relative inline-flex items-center px-2 py-2 min-w-10"
v-if="showRightEllipsis"
class="relative inline-flex min-w-10 items-center px-4 py-2"
>
&hellip;
</span>

<NuxtLink
v-if="hasNext"
class="relative inline-flex min-w-10 items-center px-2 py-2"
:to="toForPage(currentPage + 1)"
@click="goTo(currentPage + 1)"
>
<span class="sr-only">Next</span>
&gt;&gt;
</a>
</NuxtLink>
</nav>
</div>
</template>

<script setup lang="ts">
const props = withDefaults(defineProps<{
current: number
totalPages: number
maxLinks: number
}>(), {
current: 0,
totalPages: 0,
maxLinks: 8,
})

const previousURL = props.current > 0 ? `?page=${props.current - 1}` : null
const nextURL = props.current + 1 < props.totalPages ? `?page=${props.current + 1}` : null
const hellipLeft = props.current > props.maxLinks / 2 + 1
const hellipRight = props.totalPages - props.current > props.maxLinks / 2
const props = withDefaults(
defineProps<{
current: number,
totalPages: number,
maxLinks: number,
}>(),
{
current: 0,
totalPages: 0,
maxLinks: 8,
},
);

const emit = defineEmits<{
'update:current': [value: number],
}>();

const route = useRoute();

const currentPage = computed(() => Math.max(0, props.current));
const totalPages = computed(() => Math.max(0, props.totalPages));
const maxLinks = computed(() => Math.max(1, props.maxLinks));

const startIndex = computed(() => {
if (totalPages.value <= maxLinks.value) return 0;

const half = Math.floor(maxLinks.value / 2);
const maxStart = Math.max(totalPages.value - maxLinks.value, 0);

return Math.min(Math.max(currentPage.value - half, 0), maxStart);
});

const endIndex = computed(() =>
Math.min(startIndex.value + maxLinks.value - 1, totalPages.value - 1),
);

const pageLinks = computed(() => {
const links: Array<{ index: number, label: number }> = [];

for (let index = startIndex.value; index <= endIndex.value; index += 1) {
links.push({ index, label: index + 1 });
}

return links;
});

const hasPrevious = computed(() => currentPage.value > 0);
const hasNext = computed(() => currentPage.value + 1 < totalPages.value);
const showLeftEllipsis = computed(() => startIndex.value > 0);
const showRightEllipsis = computed(() => endIndex.value < totalPages.value - 1);

function goTo(page: number) {
emit('update:current', Math.max(0, page));
}

function toForPage(page: number) {
const normalizedPage = Math.max(0, page);
const query = { ...route.query } as Record<string, string | string[] | undefined>;

if (normalizedPage > 0) {
query.page = String(normalizedPage);
} else {
delete query.page;
}

return {
path: route.path,
query,
};
}
</script>
18 changes: 18 additions & 0 deletions playground/components/global/drupal-view--default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
v-if="pager.totalPages"
:total-pages="pager.totalPages"
:current="pager.current"
@update:current="onPageChange"
/>
<div v-if="slots.footer" class="view-footer">
<slot name="footer" />
Expand Down Expand Up @@ -45,5 +46,22 @@ const props = withDefaults(defineProps<{
pager: () => ({}),
});

const route = useRoute();
const router = useRouter();
const slots = useSlots();

function onPageChange(page: number) {
const query = { ...route.query } as Record<string, string | string[] | undefined>;

if (page > 0) {
query.page = String(page);
} else {
delete query.page;
}

void router.replace({
path: route.path,
query,
});
}
</script>
61 changes: 61 additions & 0 deletions playground/composables/useDrupalCeViewControls.example.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Example shape for a shared, headless view-controls composable.
*
* This is intentionally UI-agnostic and intended as reference for module-level APIs.
*/
export interface ViewControlsConfig {
path: string
viewId?: string
displayId?: string
parentUuid?: string
}

export function useDrupalCeViewControlsExample(_config: ViewControlsConfig) {
const rows = ref<unknown[]>([])
const pager = ref({ current: 0, totalPages: 1 })
const filters = ref<Record<string, string | string[]>>({})
const sorts = ref<Record<string, string>>({})
const noResults = ref('')
const isLoading = ref(false)
const error = ref('')

async function refresh() {
// 1) build query from filters/sorts/page
// 2) fetch data using Drupal CE API
// 3) find matching view node by viewId/displayId/parentUuid
// 4) update rows/pager/noResults
}

function setFilter(key: string, value: string | string[]) {
filters.value[key] = value
}

function setSort(key: string, value: string) {
sorts.value[key] = value
}

function setPage(page: number) {
pager.value.current = page
}

function reset() {
filters.value = {}
sorts.value = {}
pager.value.current = 0
}

return {
rows,
pager,
filters,
sorts,
noResults,
isLoading,
error,
refresh,
setFilter,
setSort,
setPage,
reset,
}
}
Loading