Skip to content

[Sprunje] Custom columns listing, order and responsiveness + Pinia persistence  #48

Description

@lcharette

In UF4 and UF5, TableSorter had a built in feature where table columns could be chosen by the user.
https://mottie.github.io/tablesorter/docs/example-widget-column-selector.html

Image

This is missing from UF6 as we don't use TableSorter anymore. It would be hard to add since Sprunje doesn't know the columns. On the other side it's a good opportunity to also add custom order of the column, and columns priority in responsive scenarios.

This refactoring is also a good opportunity to add another missing feature. The table order and filter is not saved on page reload. Instead of saving in the data in the url, we'll save it in a Pinia store.

Bellow is plan provided by Copilot:

Plan: Sprunje Column Registry

Add a declarative column-definition mode to Sprunje tables so the table owns stable column metadata while each column still provides custom header and cell templates. Keep the current header/body slot API working as a legacy mode, but only enable visibility/reordering/responsive preferences when columns are declared explicitly and the table has a stable table id for Pinia-backed persistence.

Steps

  1. Phase 1: Define the new column model in core. Add shared types for persisted column preferences and declarative column definitions in packages/sprinkle-core/app/assets/interfaces/sprunjer.ts and export them through the sprinkle-core interfaces barrel. The model should include at least key, label, optional sort, defaultVisible, responsive hints, and an order value or ordered key list. This phase blocks all later phases.
  2. Phase 1: Add a dedicated persisted Pinia store in packages/sprinkle-core/app/assets/stores/ for per-table column preferences, keyed by a required tableId when advanced column features are used. Store only user preference state such as ordered keys and visibility overrides, not render functions. Export the store through the sprinkle-core stores barrel. This depends on step 1.
  3. Phase 2: Introduce a table-owned column registry in theme-pink-cupcake. Add a new child component such as UFSprunjeColumnDef under packages/theme-pink-cupcake/src/components/Sprunjer/ that injects a registration API from UFSprunjeTable and registers column metadata plus header/cell slot renderers on mount. UFSprunjeTable should provide the registry and compute visible ordered columns from declared defaults plus persisted store overrides. This depends on steps 1-2.
  4. Phase 2: Update packages/theme-pink-cupcake/src/components/Sprunjer/SprunjeTable.vue to support two modes. Declarative-column mode renders headers and body by iterating the registered visible columns; legacy slot mode continues rendering the existing header and body slots unchanged for backward compatibility. Column management UI should only appear in declarative-column mode. This depends on step 3.
  5. Phase 2: Extend packages/theme-pink-cupcake/src/components/Sprunjer/SprunjeDownload.vue into a column-preferences menu backed by the registry. Keep CSV download, add a visible-columns checklist, and add move up / move down controls for ordering. The menu should disable or hide controls for columns marked as non-toggleable if that option is included in the model. This depends on steps 3-4.
  6. Phase 2: Decide how sorting metadata flows in declarative mode. Recommended approach: the column definition owns the sortable field via an optional sort prop, and table rendering reuses the current toggleSort behavior from the injected sprunjer instance. SprunjeHeader.vue can either be reused internally or reduced to a legacy helper for legacy slot mode. This depends on step 4.
  7. Phase 3: Add responsive visibility hints to the declarative column model and theme rendering. Recommended first shape: a metadata field that maps to CSS classes or simple breakpoint rules, without coupling responsive state to persisted user visibility. This lets future mobile-only behavior land without redesigning the registry. This depends on steps 1 and 4.
  8. Phase 3: Migrate one representative table, preferably packages/theme-pink-cupcake/src/views/Admin/PageUsers.vue, to the new declarative column API as the proving ground. Use that migration to validate complex header/cell templates, sortable and non-sortable columns, action columns, and persistence behavior. This depends on steps 3-7.
  9. Phase 3: After the first migration succeeds, migrate the remaining Sprunje views in theme-pink-cupcake incrementally. These migrations can run in parallel once the shared components stabilize. Legacy mode may remain temporarily to avoid a flag day, but new column features should only be documented for declarative mode.
  10. Phase 4: Add changelog entries for the affected packages under Unreleased, at minimum in packages/sprinkle-core/CHANGELOG.md and packages/theme-pink-cupcake/CHANGELOG.md, and in the root changelog only if the change is documented there already. This depends on the implementation being finalized.

Relevant files

  • packages/sprinkle-core/app/assets/interfaces/sprunjer.ts — extend the shared Sprunjer contract with column definition and preference types.
  • packages/sprinkle-core/app/assets/interfaces/index.ts — export new column-related types.
  • packages/sprinkle-core/app/assets/stores/index.ts — export the new persisted preferences store.
  • packages/sprinkle-core/app/assets/stores/useConfigStore.ts — reference for Pinia store style and persist: true pattern.
  • packages/theme-pink-cupcake/src/components/Sprunjer/SprunjeTable.vue — add registry provider, dual rendering modes, and ordered visible-column rendering.
  • packages/theme-pink-cupcake/src/components/Sprunjer/SprunjeDownload.vue — add column toggle and reordering controls beside CSV download.
  • packages/theme-pink-cupcake/src/components/Sprunjer/SprunjeHeader.vue — decide whether to reuse internally or keep as legacy-only helper.
  • packages/theme-pink-cupcake/src/components/Sprunjer/SprunjeColumn.vue — decide whether to reuse internally or keep as legacy-only helper.
  • packages/theme-pink-cupcake/src/views/Admin/PageUsers.vue — first migration candidate covering complex cell templates and actions.
  • packages/theme-pink-cupcake/src/views/Admin/PageRoles.vue and other Sprunje views — follow-on migrations once the shared API settles.

Verification

  1. Add unit/component tests for the new column registry behavior in the relevant theme-pink-cupcake test location: default visible columns, hidden columns, persisted order, move up / move down behavior, and legacy mode fallback.
  2. Add tests for the persisted preferences store in sprinkle-core: per-table isolation, merge of defaults with persisted overrides, and reset behavior when a declared column disappears.
  3. Run npm run typecheck from the repo root after the shared type and component changes.
  4. Run targeted Vitest coverage for the touched frontend packages, then npm run test if the targeted checks pass.
  5. Run npm run lint and npm run format from the repo root before handoff.
  6. Manually validate in the running app using a migrated table: toggle columns, reorder them, reload the page to confirm persistence, verify CSV download still works, and confirm complex templates render unchanged.

Decisions

  • Use declarative child components inside UFSprunjeTable as the source of truth for column metadata and templating.
  • Persist preferences per table using Pinia with a stable tableId; persistence belongs to shared state, not ad hoc local component storage.
  • Include in first implementation: column visibility toggle, stable ordering model, persistence, responsive visibility hints API, and reorder UI.
  • Implement reorder UI initially as move up / move down controls in the dropdown, not drag and drop.
  • Preserve the current header and body slot API as a legacy fallback so existing tables do not break during migration.
  • Exclude backend API changes for now; column metadata is a frontend concern in this design.

Further Considerations

  1. Require tableId only when declarative columns enable persisted preferences; legacy tables without advanced features can remain unchanged.
  2. Consider a future lockVisible or required column flag so action columns or key identity columns cannot be hidden accidentally.
  3. Document the new declarative API in the theme or skeleton docs once at least one production usage has been migrated.

A reasonable shape is a renderless registration component: it does not render a header or cell itself, it just registers column metadata plus two template functions with the parent table.

Pseudocode:

<script setup lang="ts">
import { inject, onBeforeUnmount, onMounted, useSlots } from 'vue'

type ResponsiveRule =
    | 'always'
    | 'hide-mobile'
    | 'hide-tablet'
    | { min?: 'sm' | 'md' | 'lg'; max?: 'sm' | 'md' | 'lg' }

type SprunjeColumnDefinition = {
    key: string
    label?: string
    sort?: string
    defaultVisible?: boolean
    toggleable?: boolean
    responsive?: ResponsiveRule
    order?: number
    required?: boolean
}

type RegisteredSprunjeColumn = SprunjeColumnDefinition & {
    renderHeader: () => any
    renderCell: (row: any, sprunjer: any) => any
}

type SprunjeColumnRegistry = {
    registerColumn: (column: RegisteredSprunjeColumn) => void
    unregisterColumn: (key: string) => void
    updateColumn: (column: RegisteredSprunjeColumn) => void
}

const props = withDefaults(
    defineProps<{
        key: string
        label?: string
        sort?: string
        defaultVisible?: boolean
        toggleable?: boolean
        responsive?: ResponsiveRule
        order?: number
        required?: boolean
    }>(),
    {
        defaultVisible: true,
        toggleable: true
    }
)

const slots = useSlots()

const registry = inject('sprunjeColumnRegistry') as SprunjeColumnRegistry
const sprunjer = inject('sprunjer')

function buildColumn(): RegisteredSprunjeColumn {
    return {
        key: props.key,
        label: props.label,
        sort: props.sort,
        defaultVisible: props.defaultVisible,
        toggleable: props.toggleable,
        responsive: props.responsive,
        order: props.order,
        required: props.required,

        renderHeader() {
            if (slots.header) {
                return slots.header({
                    column: props,
                    sprunjer
                })
            }

            return props.label
        },

        renderCell(row, sprunjer) {
            if (!slots.default) {
                return null
            }

            return slots.default({
                row,
                column: props,
                sprunjer
            })
        }
    }
}

onMounted(() => {
    registry.registerColumn(buildColumn())
})

onBeforeUnmount(() => {
    registry.unregisterColumn(props.key)
})

// Optional:
// watch(() => [props.label, props.sort, props.defaultVisible, props.order, props.responsive], () => {
//     registry.updateColumn(buildColumn())
// })
</script>

<template>
    <!-- Renderless: this component only declares metadata + templates -->
    <slot v-if="false" />
</template>

Usage could look like this inside the table:

<UFSprunjeTable table-id="admin-users" data-url="/api/users">
    <UFSprunjeColumnDef key="user" label="User" sort="name" :order="10">
        <template #header="{ column, sprunjer }">
            <UFSprunjeHeader :sort="column.sort">User</UFSprunjeHeader>
        </template>

        <template #default="{ row }">
            <strong>{{ row.full_name }}</strong>
            <div class="uk-text-meta">{{ row.email }}</div>
        </template>
    </UFSprunjeColumnDef>

    <UFSprunjeColumnDef
        key="status"
        label="Status"
        sort="status"
        :order="20"
        responsive="hide-mobile">
        <template #header="{ column }">
            <UFSprunjeHeader :sort="column.sort">Status</UFSprunjeHeader>
        </template>

        <template #default="{ row }">
            <UFLabel>{{ row.status }}</UFLabel>
        </template>
    </UFSprunjeColumnDef>

    <UFSprunjeColumnDef
        key="actions"
        label="Actions"
        :toggleable="false"
        :required="true"
        :order="999">
        <template #header>
            <UFSprunjeHeader>Actions</UFSprunjeHeader>
        </template>

        <template #default="{ row, sprunjer }">
            <UserActions :user="row" @saved="sprunjer.fetch()" />
        </template>
    </UFSprunjeColumnDef>
</UFSprunjeTable>

The parent table would then iterate registered, visible, ordered columns instead of relying on separate header and body slots:

<thead>
    <tr>
        <th v-for="column in visibleColumns" :key="column.key">
            <component :is="column.renderHeader" />
        </th>
    </tr>
</thead>

<tbody>
    <tr v-for="row in rows" :key="row.id">
        <td v-for="column in visibleColumns" :key="column.key">
            <component :is="column.renderCell(row, sprunjer)" />
        </td>
    </tr>
</tbody>

Two implementation notes matter:

  1. In real Vue code, the table will likely store slot functions or VNodes, not plain strings, so the registry type should be designed around render callbacks.
  2. I would avoid naming the prop key in the actual component API, because Vue already treats key specially. A safer prop name is columnKey.

If you want, I can also sketch the matching pseudocode for the parent registry inside SprunjeTable.vue so the full interaction is clear.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Breaking ChangeCreates incompatibility with current versionNew Feature PlanningNew feature request and planing.

    Projects

    Status
    Not Started

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions