import { defineCustomElements } from '@revolist/revogrid/loader';
defineCustomElements();
import type {
PivotConfig,
PivotDiagnosticsModel,
PivotDrilldownState,
PivotSavedViewRecord,
} from '@revolist/revogrid-enterprise';
import NumberColumnType from '@revolist/revogrid-column-numeral';
import {
PivotDrilldownController,
PivotPlugin,
createPivotDiagnosticsModel,
deletePivotSavedView,
duplicatePivotSavedView,
loadPivotView,
renamePivotSavedView,
savePivotView,
} from '@revolist/revogrid-enterprise';
import { PaginationPlugin, RowOddPlugin } from '@revolist/revogrid-pro';
import { currentTheme } from '../composables/useRandomData';
import {
PIVOT_REMOTE_DRILLDOWN_CELLS,
PIVOT_REMOTE_DRILLDOWN_COLUMNS,
PIVOT_REMOTE_FIELDS_VERSION,
PIVOT_REMOTE_SAVED_VIEW_PRESETS,
PIVOT_REMOTE_VIEW_ID,
createPivotRemoteActivityHooks,
createPivotRemoteDiagnosticsRequest,
createPivotRemoteStore,
serializePivotRemoteToolsLayout,
type PivotRemoteToolsActivity,
withPivotRemoteEngine,
withPivotRemoteToolsUi,
} from '../sys-data/pivot.remote';
const USER_ID = 'portal-demo-user';
export function load(parentSelector: string) {
const parent = document.querySelector(parentSelector);
if (!parent) {
return () => undefined;
}
const { isDark } = currentTheme();
const activity: PivotRemoteToolsActivity[] = [];
let diagnostics: PivotDiagnosticsModel | null = null;
let drilldownState: PivotDrilldownState;
let views: PivotSavedViewRecord[] = [];
let activeViewId = PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].viewId;
let viewName = PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].name;
let activeDrilldownId = PIVOT_REMOTE_DRILLDOWN_CELLS[0].id;
let currentLayoutConfig: Partial<PivotConfig> = PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].config;
let message = 'Use the grid field panel to change dimensions, then save the layout.';
const store = createPivotRemoteStore(createPivotRemoteActivityHooks((entry) => {
activity.unshift(entry);
activity.splice(8);
render();
}));
const drilldown = new PivotDrilldownController(store, { defaultLimit: 5 });
drilldownState = drilldown.getState();
const createGridPivot = (config: Partial<PivotConfig>) => withPivotRemoteEngine(withPivotRemoteToolsUi(config), store);
const root = document.createElement('div');
root.className = 'flex h-full min-h-[620px] gap-3';
const panel = document.createElement('aside');
panel.className = 'w-[320px] shrink-0 space-y-3 overflow-auto border-r border-gray-200 pr-3 text-sm dark:border-gray-700';
const gridShell = document.createElement('div');
gridShell.className = 'grid-shell h-full min-w-0 grow overflow-hidden';
const grid = document.createElement('revo-grid');
grid.className = 'h-full w-full cell-border';
grid.range = true;
grid.resize = true;
grid.readonly = true;
grid.hideAttribution = true;
grid.theme = isDark() ? 'darkCompact' : 'compact';
grid.colSize = 180;
grid.columnTypes = {
currency: new NumberColumnType('$0,0.00'),
};
grid.plugins = [PivotPlugin, PaginationPlugin, RowOddPlugin] as any;
Object.assign(grid, {
pivot: createGridPivot(PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].config),
pagination: {
itemsPerPage: 3,
initialPage: 0,
total: 0,
},
});
const unsubscribe = drilldown.subscribe((state) => {
drilldownState = state;
render();
});
function render() {
const activeViews = getActiveViews();
const customViews = activeViews.filter((view) => !getPreset(view.viewId));
const activePreset = getPreset(activeViewId);
panel.innerHTML = `
<h3 class="m-0 text-base font-semibold">Remote Pivot tools</h3>
<p class="m-0 text-xs text-gray-500">${message}</p>
<section class="space-y-2">
<h4 class="m-0 font-semibold">Saved layouts</h4>
<select data-action="select-view" class="w-full rounded border px-2 py-1 text-xs dark:border-gray-700 dark:bg-gray-900">
${PIVOT_REMOTE_SAVED_VIEW_PRESETS.map((preset) => `<option value="${preset.viewId}" ${preset.viewId === activeViewId ? 'selected' : ''}>${preset.name}</option>`).join('')}
${customViews.map((view) => `<option value="${view.viewId}" ${view.viewId === activeViewId ? 'selected' : ''}>${getViewName(view)}</option>`).join('')}
</select>
<p class="m-0 text-xs text-gray-500">${activePreset?.description ?? 'Saved field layout from the grid panel.'}</p>
<div class="flex flex-wrap gap-1">
<button data-action="save" class="rounded border px-2 py-1">Save current layout</button>
<button data-action="rename" class="rounded border px-2 py-1">Rename</button>
<button data-action="duplicate" class="rounded border px-2 py-1">Duplicate</button>
<button data-action="delete" class="rounded border px-2 py-1">Delete</button>
</div>
<div class="max-h-24 space-y-1 overflow-auto text-xs text-gray-600 dark:text-gray-300">
${activeViews.map((view) => `<div>${view.viewId}: ${getViewName(view)}</div>`).join('') || 'Save a preset to create a user-owned view.'}
</div>
</section>
<section class="space-y-2">
<h4 class="m-0 font-semibold">Drilldown</h4>
<select data-action="select-drilldown" class="w-full rounded border px-2 py-1 text-xs dark:border-gray-700 dark:bg-gray-900">
${PIVOT_REMOTE_DRILLDOWN_CELLS.map((item) => `<option value="${item.id}" ${item.id === activeDrilldownId ? 'selected' : ''}>${item.label}</option>`).join('')}
</select>
<div class="flex flex-wrap gap-1">
<button data-action="drilldown" class="rounded border px-2 py-1">Load source rows</button>
<button data-action="reset-drilldown" class="rounded border px-2 py-1">Reset</button>
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
${drilldownState.status} - ${drilldownState.rows.length} of ${drilldownState.totalCount} rows - ${drilldownState.columns.length} columns
</div>
<div class="max-h-36 overflow-auto rounded border border-gray-200 dark:border-gray-700">
${renderDrilldownTable(drilldownState)}
</div>
</section>
<section class="space-y-2">
<h4 class="m-0 font-semibold">Diagnostics</h4>
<button data-action="diagnostics" class="rounded border px-2 py-1">Run diagnostics</button>
<div class="space-y-1 text-xs text-gray-600 dark:text-gray-300">
${diagnostics?.rows.map((row) => `<div>${row.label}: ${row.value}</div>`).join('') ?? 'No diagnostics yet'}
</div>
<div class="flex flex-wrap gap-1">
${diagnostics?.chips.map((chip) => `<span class="rounded border px-2 py-0.5 text-xs ${chipClass(chip.tone)}">${chip.label}</span>`).join('') ?? ''}
</div>
</section>
<section class="space-y-2">
<h4 class="m-0 font-semibold">Remote activity</h4>
<div class="max-h-28 space-y-1 overflow-auto text-xs text-gray-600 dark:text-gray-300">
${activity.map((entry) => `<div class="${activityClass(entry.tone)}">${entry.label}</div>`).join('') || 'No remote activity yet.'}
</div>
</section>
`;
}
async function runDiagnostics() {
const response = await store.load(createPivotRemoteDiagnosticsRequest(getCurrentLayoutConfig()));
diagnostics = createPivotDiagnosticsModel(response, {
formatGeneratedAt: (value) => new Date(value).toLocaleTimeString(),
});
message = 'Diagnostics refreshed for the selected layout.';
render();
}
async function loadFacts() {
const activeDrilldown = getActiveDrilldown();
await drilldown.load({
viewId: PIVOT_REMOTE_VIEW_ID,
fieldsVersion: PIVOT_REMOTE_FIELDS_VERSION,
cell: activeDrilldown.cell,
customColumns: PIVOT_REMOTE_DRILLDOWN_COLUMNS,
});
message = `Loaded source rows for ${activeDrilldown.label}.`;
render();
}
async function saveView() {
const preset = getPreset(activeViewId);
const targetViewId = preset ? `custom-layout-${getActiveViews().length + 1}` : activeViewId;
const targetName = preset ? `Saved Layout ${getActiveViews().length + 1}` : viewName;
const saved = await savePivotView(store, {
userId: USER_ID,
viewId: targetViewId,
name: targetName,
config: getCurrentLayoutConfig(),
});
views = [...views.filter((view) => view.viewId !== saved.viewId), saved];
activeViewId = saved.viewId;
viewName = saved.name ?? saved.viewId;
currentLayoutConfig = saved.config;
Object.assign(grid, { pivot: createGridPivot(saved.config) });
message = `Saved ${saved.name ?? saved.viewId}.`;
render();
}
panel.addEventListener('change', (event) => {
const target = event.target as HTMLSelectElement;
if (target.dataset.action === 'select-view') {
void selectView(target.value);
}
if (target.dataset.action === 'select-drilldown') {
activeDrilldownId = target.value;
render();
}
});
panel.addEventListener('click', (event) => {
const action = (event.target as HTMLElement).dataset.action;
if (!action) return;
void (async () => {
if (action === 'diagnostics') {
await runDiagnostics();
} else if (action === 'drilldown') {
await loadFacts();
} else if (action === 'reset-drilldown') {
drilldown.reset();
message = 'Drilldown reset.';
render();
} else if (action === 'save') {
await saveView();
} else if (action === 'rename') {
renameView();
} else if (action === 'duplicate') {
duplicateView();
} else if (action === 'delete') {
deleteView();
}
})().catch((error) => {
message = error instanceof Error ? error.message : String(error);
render();
});
});
function renameView() {
if (!hasActiveView(views, activeViewId)) {
message = 'Save a view before renaming.';
render();
return;
}
const nextName = `${viewName} Renamed`;
views = renamePivotSavedView(views, { userId: USER_ID, viewId: activeViewId, name: nextName });
viewName = nextName;
message = `Renamed ${activeViewId}.`;
render();
}
async function selectView(viewId: string) {
activeViewId = viewId;
const preset = getPreset(viewId);
if (preset) {
viewName = preset.name;
currentLayoutConfig = preset.config;
Object.assign(grid, { pivot: createGridPivot(preset.config) });
message = `Loaded ${preset.name} preset into the grid.`;
render();
return;
}
const saved = await loadPivotView(store, { userId: USER_ID, viewId });
viewName = saved.name ?? saved.viewId;
currentLayoutConfig = saved.config;
Object.assign(grid, { pivot: createGridPivot(saved.config) });
message = `Loaded ${saved.name ?? saved.viewId} into the grid.`;
render();
}
function duplicateView() {
if (!hasActiveView(views, activeViewId)) {
message = 'Save a view before duplicating.';
render();
return;
}
const newViewId = `${activeViewId}-copy-${getActiveViews().length + 1}`;
const nextName = `${viewName} Copy`;
views = duplicatePivotSavedView(views, {
userId: USER_ID,
viewId: activeViewId,
newViewId,
name: nextName,
});
activeViewId = newViewId;
viewName = nextName;
const duplicate = getActiveViews().find((view) => view.viewId === newViewId);
if (duplicate && 'config' in duplicate) {
currentLayoutConfig = duplicate.config;
Object.assign(grid, { pivot: createGridPivot(duplicate.config) });
}
message = `Duplicated as ${newViewId}.`;
render();
}
function deleteView() {
if (!hasActiveView(views, activeViewId)) {
message = 'Save a view before deleting.';
render();
return;
}
views = deletePivotSavedView(views, { userId: USER_ID, viewId: activeViewId });
activeViewId = PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].viewId;
viewName = PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].name;
currentLayoutConfig = PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].config;
Object.assign(grid, { pivot: createGridPivot(PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].config) });
message = 'Deleted active view.';
render();
}
function getCurrentLayoutConfig(): Partial<PivotConfig> {
return serializePivotRemoteToolsLayout(currentLayoutConfig);
}
function getActiveViews() {
return views.filter((view) => !('deleted' in view));
}
function getActiveDrilldown() {
return PIVOT_REMOTE_DRILLDOWN_CELLS.find((item) => item.id === activeDrilldownId) ?? PIVOT_REMOTE_DRILLDOWN_CELLS[0];
}
render();
gridShell.append(grid);
root.append(panel, gridShell);
parent.appendChild(root);
grid.source = [];
const onPivotConfigUpdate = (event: Event) => {
currentLayoutConfig = serializePivotRemoteToolsLayout((event as CustomEvent<PivotConfig>).detail ?? currentLayoutConfig);
Object.assign(grid, { pivot: createGridPivot(currentLayoutConfig) });
message = 'Layout changed in the field panel. Save it to keep this arrangement.';
render();
};
grid.addEventListener('pivot-config-update', onPivotConfigUpdate);
void runDiagnostics();
return () => {
unsubscribe();
grid.removeEventListener('pivot-config-update', onPivotConfigUpdate);
drilldown.abort();
root.remove();
};
}
function renderDrilldownTable(state: PivotDrilldownState) {
const headers = state.columns.map((column) => {
return `<th class="border-b px-2 py-1 text-left font-semibold dark:border-gray-700">${column.name ?? String(column.prop)}</th>`;
}).join('');
const rows = state.rows.map((row) => {
const cells = state.columns.map((column) => {
return `<td class="border-b px-2 py-1 dark:border-gray-800">${String(row[column.prop] ?? '')}</td>`;
}).join('');
return `<tr>${cells}</tr>`;
}).join('');
const empty = state.rows.length
? ''
: `<tr><td class="px-2 py-2 text-gray-500" colspan="${Math.max(state.columns.length, 1)}">No source rows loaded.</td></tr>`;
return `<table class="w-full border-collapse text-xs"><thead><tr>${headers}</tr></thead><tbody>${rows}${empty}</tbody></table>`;
}
function getPreset(viewId: string) {
return PIVOT_REMOTE_SAVED_VIEW_PRESETS.find((preset) => preset.viewId === viewId);
}
function getViewName(view: PivotSavedViewRecord) {
return 'name' in view ? view.name ?? '(unnamed)' : '(deleted)';
}
function hasActiveView(views: readonly PivotSavedViewRecord[], viewId: string) {
return views.some((view) => view.viewId === viewId && !('deleted' in view));
}
function chipClass(tone: string) {
if (tone === 'warning') return 'border-amber-300 text-amber-700';
if (tone === 'success') return 'border-emerald-300 text-emerald-700';
return 'border-gray-300 text-gray-600';
}
function activityClass(tone: string) {
if (tone === 'warning') return 'text-amber-700';
if (tone === 'success') return 'text-emerald-700';
return '';
}
<template>
<div class="flex h-full min-h-[620px] gap-3">
<aside class="w-[320px] shrink-0 space-y-3 overflow-auto border-r border-gray-200 pr-3 text-sm dark:border-gray-700">
<h3 class="m-0 text-base font-semibold">Remote Pivot tools</h3>
<p class="m-0 text-xs text-gray-500">{{ message }}</p>
<section class="space-y-2">
<h4 class="m-0 font-semibold">Saved layouts</h4>
<select :value="activeViewId" class="w-full rounded border px-2 py-1 text-xs dark:border-gray-700 dark:bg-gray-900" @change="selectView">
<option v-for="preset in PIVOT_REMOTE_SAVED_VIEW_PRESETS" :key="preset.viewId" :value="preset.viewId">
{{ preset.name }}
</option>
<option v-for="view in customViews" :key="view.viewId" :value="view.viewId">
{{ getViewName(view) }}
</option>
</select>
<p class="m-0 text-xs text-gray-500">{{ activePreset?.description ?? 'Saved field layout from the grid panel.' }}</p>
<div class="flex flex-wrap gap-1">
<button class="rounded border px-2 py-1" @click="saveView">Save current layout</button>
<button class="rounded border px-2 py-1" @click="renameView">Rename</button>
<button class="rounded border px-2 py-1" @click="duplicateView">Duplicate</button>
<button class="rounded border px-2 py-1" @click="deleteView">Delete</button>
</div>
<div class="max-h-24 space-y-1 overflow-auto text-xs text-gray-600 dark:text-gray-300">
<div v-for="view in activeViews" :key="view.viewId">
{{ view.viewId }}: {{ getViewName(view) }}
</div>
<div v-if="!activeViews.length">Save a preset to create a user-owned view.</div>
</div>
</section>
<section class="space-y-2">
<h4 class="m-0 font-semibold">Drilldown</h4>
<select v-model="activeDrilldownId" class="w-full rounded border px-2 py-1 text-xs dark:border-gray-700 dark:bg-gray-900">
<option v-for="item in PIVOT_REMOTE_DRILLDOWN_CELLS" :key="item.id" :value="item.id">
{{ item.label }}
</option>
</select>
<div class="flex flex-wrap gap-1">
<button class="rounded border px-2 py-1" @click="loadFacts">Load source rows</button>
<button class="rounded border px-2 py-1" @click="resetDrilldown">Reset</button>
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ drilldownState.status }} - {{ drilldownState.rows.length }} of {{ drilldownState.totalCount }} rows - {{ drilldownState.columns.length }} columns
</div>
<div class="max-h-36 overflow-auto rounded border border-gray-200 dark:border-gray-700">
<table class="w-full border-collapse text-xs">
<thead>
<tr>
<th v-for="column in drilldownState.columns" :key="String(column.prop)" class="border-b px-2 py-1 text-left font-semibold dark:border-gray-700">
{{ column.name ?? column.prop }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in drilldownState.rows" :key="rowIndex">
<td v-for="column in drilldownState.columns" :key="String(column.prop)" class="border-b px-2 py-1 dark:border-gray-800">
{{ row[column.prop] }}
</td>
</tr>
<tr v-if="!drilldownState.rows.length">
<td class="px-2 py-2 text-gray-500" :colspan="Math.max(drilldownState.columns.length, 1)">
No source rows loaded.
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="space-y-2">
<h4 class="m-0 font-semibold">Diagnostics</h4>
<button class="rounded border px-2 py-1" @click="runDiagnostics">Run diagnostics</button>
<div class="space-y-1 text-xs text-gray-600 dark:text-gray-300">
<div v-for="row in diagnostics?.rows ?? []" :key="row.id">{{ row.label }}: {{ row.value }}</div>
<div v-if="!diagnostics">No diagnostics yet</div>
</div>
<div class="flex flex-wrap gap-1">
<span
v-for="chip in diagnostics?.chips ?? []"
:key="chip.id"
class="rounded border px-2 py-0.5 text-xs"
:class="chip.tone === 'warning' ? 'border-amber-300 text-amber-700' : chip.tone === 'success' ? 'border-emerald-300 text-emerald-700' : 'border-gray-300 text-gray-600'"
>
{{ chip.label }}
</span>
</div>
</section>
<section class="space-y-2">
<h4 class="m-0 font-semibold">Remote activity</h4>
<div class="max-h-28 space-y-1 overflow-auto text-xs text-gray-600 dark:text-gray-300">
<div
v-for="entry in activity"
:key="entry.id"
:class="entry.tone === 'warning' ? 'text-amber-700' : entry.tone === 'success' ? 'text-emerald-700' : ''"
>
{{ entry.label }}
</div>
<div v-if="!activity.length">No remote activity yet.</div>
</div>
</section>
</aside>
<div class="grid-shell h-full min-w-0 grow overflow-hidden">
<RevoGrid
class="h-full w-full cell-border"
hide-attribution
range
resize
readonly
:colSize="180"
:source="[]"
:pivot.prop="pivot"
:additionalData="additionalData"
:theme="isDark ? 'darkCompact' : 'compact'"
:plugins="plugins"
:column-types="columnTypes"
@pivot-config-update="configUpdate"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import RevoGrid, { type GridPlugin } from '@revolist/vue3-datagrid';
import NumberColumnType from '@revolist/revogrid-column-numeral';
import {
PivotDrilldownController,
PivotPlugin,
createPivotDiagnosticsModel,
deletePivotSavedView,
duplicatePivotSavedView,
loadPivotView,
renamePivotSavedView,
savePivotView,
type PivotConfig,
type PivotDiagnosticsModel,
type PivotDrilldownState,
type PivotSavedViewRecord,
} from '@revolist/revogrid-enterprise';
import { PaginationPlugin, RowOddPlugin } from '@revolist/revogrid-pro';
import { currentThemeVue } from '../composables/useRandomData';
import {
PIVOT_REMOTE_DRILLDOWN_CELLS,
PIVOT_REMOTE_DRILLDOWN_COLUMNS,
PIVOT_REMOTE_FIELDS_VERSION,
PIVOT_REMOTE_SAVED_VIEW_PRESETS,
PIVOT_REMOTE_VIEW_ID,
createPivotRemoteActivityHooks,
createPivotRemoteDiagnosticsRequest,
createPivotRemoteStore,
serializePivotRemoteToolsLayout,
type PivotRemoteToolsActivity,
withPivotRemoteEngine,
withPivotRemoteToolsUi,
} from '../sys-data/pivot.remote';
const USER_ID = 'portal-demo-user';
const { isDark } = currentThemeVue();
const activity = ref<PivotRemoteToolsActivity[]>([]);
const store = createPivotRemoteStore(createPivotRemoteActivityHooks(addActivity));
const drilldown = new PivotDrilldownController(store, { defaultLimit: 5 });
const diagnostics = ref<PivotDiagnosticsModel | null>(null);
const drilldownState = ref<PivotDrilldownState>(drilldown.getState());
const views = ref<PivotSavedViewRecord[]>([]);
const activeViewId = ref(PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].viewId);
const viewName = ref(PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].name);
const activeDrilldownId = ref(PIVOT_REMOTE_DRILLDOWN_CELLS[0].id);
const pivotConfig = ref(createGridPivot(PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].config));
const message = ref('Use the grid field panel to change dimensions, then save the layout.');
const columnTypes = ref({
currency: new NumberColumnType('$0,0.00'),
});
const plugins: GridPlugin[] = [PivotPlugin, PaginationPlugin, RowOddPlugin];
const pivot = computed(() => pivotConfig.value);
const additionalData = computed(() => ({
pagination: {
itemsPerPage: 3,
initialPage: 0,
total: 0,
},
}));
const activeViews = computed(() => views.value.filter((view) => !('deleted' in view)));
const customViews = computed(() => activeViews.value.filter((view) => !getPreset(view.viewId)));
const activePreset = computed(() => getPreset(activeViewId.value));
const activeDrilldown = computed(() => {
return PIVOT_REMOTE_DRILLDOWN_CELLS.find((item) => item.id === activeDrilldownId.value) ?? PIVOT_REMOTE_DRILLDOWN_CELLS[0];
});
let unsubscribe: () => void = () => undefined;
onMounted(() => {
unsubscribe = drilldown.subscribe((state) => {
drilldownState.value = state;
});
void runDiagnostics();
});
onUnmounted(() => {
unsubscribe();
drilldown.abort();
});
async function runDiagnostics() {
const response = await store.load(createPivotRemoteDiagnosticsRequest(getCurrentLayoutConfig()));
diagnostics.value = createPivotDiagnosticsModel(response, {
formatGeneratedAt: (value) => new Date(value).toLocaleTimeString(),
});
message.value = 'Diagnostics refreshed for the selected layout.';
}
async function loadFacts() {
await drilldown.load({
viewId: PIVOT_REMOTE_VIEW_ID,
fieldsVersion: PIVOT_REMOTE_FIELDS_VERSION,
cell: activeDrilldown.value.cell,
customColumns: PIVOT_REMOTE_DRILLDOWN_COLUMNS,
});
message.value = `Loaded source rows for ${activeDrilldown.value.label}.`;
}
function resetDrilldown() {
drilldown.reset();
message.value = 'Drilldown reset.';
}
async function saveView() {
const preset = getPreset(activeViewId.value);
const targetViewId = preset ? `custom-layout-${activeViews.value.length + 1}` : activeViewId.value;
const targetName = preset ? `Saved Layout ${activeViews.value.length + 1}` : viewName.value;
const saved = await savePivotView(store, {
userId: USER_ID,
viewId: targetViewId,
name: targetName,
config: getCurrentLayoutConfig(),
});
views.value = [...views.value.filter((view) => view.viewId !== saved.viewId), saved];
activeViewId.value = saved.viewId;
viewName.value = saved.name ?? saved.viewId;
pivotConfig.value = createGridPivot(saved.config);
message.value = `Saved ${saved.name ?? saved.viewId}.`;
}
async function selectView(event: Event) {
const viewId = (event.target as HTMLSelectElement).value;
activeViewId.value = viewId;
const preset = getPreset(viewId);
if (preset) {
viewName.value = preset.name;
pivotConfig.value = createGridPivot(preset.config);
message.value = `Loaded ${preset.name} preset into the grid.`;
return;
}
const saved = await loadPivotView(store, { userId: USER_ID, viewId });
viewName.value = saved.name ?? saved.viewId;
pivotConfig.value = createGridPivot(saved.config);
message.value = `Loaded ${saved.name ?? saved.viewId} into the grid.`;
}
function renameView() {
if (!hasActiveView(views.value, activeViewId.value)) {
message.value = 'Save a view before renaming.';
return;
}
const nextName = `${viewName.value} Renamed`;
views.value = renamePivotSavedView(views.value, { userId: USER_ID, viewId: activeViewId.value, name: nextName });
viewName.value = nextName;
message.value = `Renamed ${activeViewId.value}.`;
}
function duplicateView() {
if (!hasActiveView(views.value, activeViewId.value)) {
message.value = 'Save a view before duplicating.';
return;
}
const newViewId = `${activeViewId.value}-copy-${activeViews.value.length + 1}`;
const nextName = `${viewName.value} Copy`;
views.value = duplicatePivotSavedView(views.value, {
userId: USER_ID,
viewId: activeViewId.value,
newViewId,
name: nextName,
});
activeViewId.value = newViewId;
viewName.value = nextName;
const duplicate = activeViews.value.find((view) => view.viewId === newViewId);
if (duplicate && 'config' in duplicate) {
pivotConfig.value = createGridPivot(duplicate.config);
}
message.value = `Duplicated as ${newViewId}.`;
}
function deleteView() {
if (!hasActiveView(views.value, activeViewId.value)) {
message.value = 'Save a view before deleting.';
return;
}
views.value = deletePivotSavedView(views.value, { userId: USER_ID, viewId: activeViewId.value });
activeViewId.value = PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].viewId;
viewName.value = PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].name;
pivotConfig.value = createGridPivot(PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].config);
message.value = 'Deleted active view.';
}
function configUpdate(event: CustomEvent<PivotConfig>) {
pivotConfig.value = createGridPivot(event.detail ?? getCurrentLayoutConfig());
message.value = 'Layout changed in the field panel. Save it to keep this arrangement.';
}
function getCurrentLayoutConfig(): Partial<PivotConfig> {
return serializePivotRemoteToolsLayout(pivotConfig.value);
}
function createGridPivot(config: Partial<PivotConfig>) {
return withPivotRemoteEngine(withPivotRemoteToolsUi(config), store);
}
function getPreset(viewId: string) {
return PIVOT_REMOTE_SAVED_VIEW_PRESETS.find((preset) => preset.viewId === viewId);
}
function getViewName(view: PivotSavedViewRecord) {
return 'name' in view ? view.name ?? '(unnamed)' : '(deleted)';
}
function hasActiveView(views: readonly PivotSavedViewRecord[], viewId: string) {
return views.some((view) => view.viewId === viewId && !('deleted' in view));
}
function addActivity(entry: PivotRemoteToolsActivity) {
activity.value = [entry, ...activity.value].slice(0, 8);
}
</script>
import { useEffect, useMemo, useRef, useState } from 'react';
import { RevoGrid } from '@revolist/react-datagrid';
import NumberColumnType from '@revolist/revogrid-column-numeral';
import {
PivotDrilldownController,
PivotPlugin,
createPivotDiagnosticsModel,
deletePivotSavedView,
duplicatePivotSavedView,
loadPivotView,
renamePivotSavedView,
savePivotView,
type PivotConfig,
type PivotDiagnosticsModel,
type PivotDrilldownState,
type PivotSavedViewRecord,
} from '@revolist/revogrid-enterprise';
import { PaginationPlugin, RowOddPlugin } from '@revolist/revogrid-pro';
import { currentTheme } from '../composables/useRandomData';
import {
PIVOT_REMOTE_DRILLDOWN_CELLS,
PIVOT_REMOTE_DRILLDOWN_COLUMNS,
PIVOT_REMOTE_FIELDS_VERSION,
PIVOT_REMOTE_SAVED_VIEW_PRESETS,
PIVOT_REMOTE_VIEW_ID,
createPivotRemoteActivityHooks,
createPivotRemoteDiagnosticsRequest,
createPivotRemoteStore,
serializePivotRemoteToolsLayout,
type PivotRemoteToolsActivity,
withPivotRemoteEngine,
withPivotRemoteToolsUi,
} from '../sys-data/pivot.remote';
const USER_ID = 'portal-demo-user';
function PivotRemoteTools() {
const { isDark } = currentTheme();
const [activity, setActivity] = useState<PivotRemoteToolsActivity[]>([]);
const store = useMemo(() => createPivotRemoteStore(createPivotRemoteActivityHooks((entry) => {
setActivity((current) => [entry, ...current].slice(0, 8));
})), []);
const createGridPivot = (config: Partial<PivotConfig>) => withPivotRemoteEngine(withPivotRemoteToolsUi(config), store);
const drilldown = useMemo(() => new PivotDrilldownController(store, { defaultLimit: 5 }), [store]);
const [diagnostics, setDiagnostics] = useState<PivotDiagnosticsModel | null>(null);
const [drilldownState, setDrilldownState] = useState<PivotDrilldownState>(drilldown.getState());
const [views, setViews] = useState<PivotSavedViewRecord[]>([]);
const [activeViewId, setActiveViewId] = useState(PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].viewId);
const [viewName, setViewName] = useState(PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].name);
const [activeDrilldownId, setActiveDrilldownId] = useState(PIVOT_REMOTE_DRILLDOWN_CELLS[0].id);
const [pivotConfig, setPivotConfig] = useState(() => createGridPivot(PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].config));
const [message, setMessage] = useState('Use the grid field panel to change dimensions, then save the layout.');
const gridRef = useRef<HTMLRevoGridElement>(null);
const columnTypes = useMemo(
() => ({
currency: new NumberColumnType('$0,0.00'),
}),
[],
);
const plugins = useMemo(() => [PivotPlugin, PaginationPlugin, RowOddPlugin] as any, []);
const pivot = useMemo(() => pivotConfig, [pivotConfig]);
const additionalData = useMemo(
() => ({
pagination: {
itemsPerPage: 3,
initialPage: 0,
total: 0,
},
}),
[],
);
const activeViews = views.filter((view) => !('deleted' in view));
const customViews = activeViews.filter((view) => !getPreset(view.viewId));
const activePreset = getPreset(activeViewId);
const activeDrilldown = PIVOT_REMOTE_DRILLDOWN_CELLS.find((item) => item.id === activeDrilldownId)
?? PIVOT_REMOTE_DRILLDOWN_CELLS[0];
useEffect(() => drilldown.subscribe(setDrilldownState), [drilldown]);
useEffect(() => {
const grid = gridRef.current;
if (!grid) return;
const handler = (event: Event) => {
const detail = (event as CustomEvent<PivotConfig>).detail ?? getCurrentLayoutConfig();
setPivotConfig(createGridPivot(detail));
setMessage('Layout changed in the field panel. Save it to keep this arrangement.');
};
grid.addEventListener('pivot-config-update', handler);
return () => grid.removeEventListener('pivot-config-update', handler);
}, [store]);
useEffect(() => {
void runDiagnostics();
}, []);
async function runDiagnostics() {
const response = await store.load(createPivotRemoteDiagnosticsRequest(getCurrentLayoutConfig()));
setDiagnostics(createPivotDiagnosticsModel(response, {
formatGeneratedAt: (value) => new Date(value).toLocaleTimeString(),
}));
setMessage('Diagnostics refreshed for the selected layout.');
}
async function loadFacts() {
await drilldown.load({
viewId: PIVOT_REMOTE_VIEW_ID,
fieldsVersion: PIVOT_REMOTE_FIELDS_VERSION,
cell: activeDrilldown.cell,
customColumns: PIVOT_REMOTE_DRILLDOWN_COLUMNS,
});
setMessage(`Loaded source rows for ${activeDrilldown.label}.`);
}
function resetDrilldown() {
drilldown.reset();
setMessage('Drilldown reset.');
}
async function saveView() {
const preset = getPreset(activeViewId);
const targetViewId = preset ? `custom-layout-${activeViews.length + 1}` : activeViewId;
const targetName = preset ? `Saved Layout ${activeViews.length + 1}` : viewName;
const saved = await savePivotView(store, {
userId: USER_ID,
viewId: targetViewId,
name: targetName,
config: getCurrentLayoutConfig(),
});
setViews((current) => [...current.filter((view) => view.viewId !== saved.viewId), saved]);
setActiveViewId(saved.viewId);
setViewName(saved.name ?? saved.viewId);
setPivotConfig(createGridPivot(saved.config));
setMessage(`Saved ${saved.name ?? saved.viewId}.`);
}
function renameView() {
if (!hasActiveView(views, activeViewId)) {
setMessage('Save a view before renaming.');
return;
}
const nextName = `${viewName} Renamed`;
setViews((current) => renamePivotSavedView(current, { userId: USER_ID, viewId: activeViewId, name: nextName }));
setViewName(nextName);
setMessage(`Renamed ${activeViewId}.`);
}
function duplicateView() {
if (!hasActiveView(views, activeViewId)) {
setMessage('Save a view before duplicating.');
return;
}
const newViewId = `${activeViewId}-copy-${activeViews.length + 1}`;
const nextName = `${viewName} Copy`;
const nextViews = duplicatePivotSavedView(views, {
userId: USER_ID,
viewId: activeViewId,
newViewId,
name: nextName,
});
const duplicate = nextViews.find((view) => view.viewId === newViewId);
setViews(nextViews);
setActiveViewId(newViewId);
setViewName(nextName);
if (duplicate && 'config' in duplicate) {
setPivotConfig(createGridPivot(duplicate.config));
}
setMessage(`Duplicated as ${newViewId}.`);
}
function deleteView() {
if (!hasActiveView(views, activeViewId)) {
setMessage('Save a view before deleting.');
return;
}
setViews((current) => deletePivotSavedView(current, { userId: USER_ID, viewId: activeViewId }));
setActiveViewId(PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].viewId);
setViewName(PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].name);
setPivotConfig(createGridPivot(PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].config));
setMessage('Deleted active view.');
}
async function selectView(viewId: string) {
setActiveViewId(viewId);
const preset = getPreset(viewId);
if (preset) {
setViewName(preset.name);
setPivotConfig(createGridPivot(preset.config));
setMessage(`Loaded ${preset.name} preset into the grid.`);
return;
}
const saved = await loadPivotView(store, { userId: USER_ID, viewId });
setViewName(saved.name ?? saved.viewId);
setPivotConfig(createGridPivot(saved.config));
setMessage(`Loaded ${saved.name ?? saved.viewId} into the grid.`);
}
function getCurrentLayoutConfig(): Partial<PivotConfig> {
return serializePivotRemoteToolsLayout(pivotConfig);
}
return (
<div className="flex h-full min-h-[620px] gap-3">
<aside className="w-[320px] shrink-0 space-y-3 overflow-auto border-r border-gray-200 pr-3 text-sm dark:border-gray-700">
<h3 className="m-0 text-base font-semibold">Remote Pivot tools</h3>
<p className="m-0 text-xs text-gray-500">{message}</p>
<section className="space-y-2">
<h4 className="m-0 font-semibold">Saved layouts</h4>
<select
value={activeViewId}
className="w-full rounded border px-2 py-1 text-xs dark:border-gray-700 dark:bg-gray-900"
onChange={(event) => void selectView(event.currentTarget.value)}
>
{PIVOT_REMOTE_SAVED_VIEW_PRESETS.map((preset) => (
<option key={preset.viewId} value={preset.viewId}>{preset.name}</option>
))}
{customViews.map((view) => (
<option key={view.viewId} value={view.viewId}>{getViewName(view)}</option>
))}
</select>
<p className="m-0 text-xs text-gray-500">{activePreset?.description ?? 'Saved field layout from the grid panel.'}</p>
<div className="flex flex-wrap gap-1">
<button className="rounded border px-2 py-1" onClick={() => void saveView()}>Save current layout</button>
<button className="rounded border px-2 py-1" onClick={renameView}>Rename</button>
<button className="rounded border px-2 py-1" onClick={duplicateView}>Duplicate</button>
<button className="rounded border px-2 py-1" onClick={deleteView}>Delete</button>
</div>
<div className="max-h-24 space-y-1 overflow-auto text-xs text-gray-600 dark:text-gray-300">
{activeViews.length ? activeViews.map((view) => (
<div key={view.viewId}>{view.viewId}: {getViewName(view)}</div>
)) : 'Save a preset to create a user-owned view.'}
</div>
</section>
<section className="space-y-2">
<h4 className="m-0 font-semibold">Drilldown</h4>
<select
value={activeDrilldownId}
className="w-full rounded border px-2 py-1 text-xs dark:border-gray-700 dark:bg-gray-900"
onChange={(event) => setActiveDrilldownId(event.currentTarget.value)}
>
{PIVOT_REMOTE_DRILLDOWN_CELLS.map((item) => (
<option key={item.id} value={item.id}>{item.label}</option>
))}
</select>
<div className="flex flex-wrap gap-1">
<button className="rounded border px-2 py-1" onClick={() => void loadFacts()}>Load source rows</button>
<button className="rounded border px-2 py-1" onClick={resetDrilldown}>Reset</button>
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
{drilldownState.status} - {drilldownState.rows.length} of {drilldownState.totalCount} rows - {drilldownState.columns.length} columns
</div>
<div className="max-h-36 overflow-auto rounded border border-gray-200 dark:border-gray-700">
<table className="w-full border-collapse text-xs">
<thead>
<tr>
{drilldownState.columns.map((column) => (
<th key={String(column.prop)} className="border-b px-2 py-1 text-left font-semibold dark:border-gray-700">
{column.name ?? column.prop}
</th>
))}
</tr>
</thead>
<tbody>
{drilldownState.rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{drilldownState.columns.map((column) => (
<td key={String(column.prop)} className="border-b px-2 py-1 dark:border-gray-800">
{String(row[column.prop] ?? '')}
</td>
))}
</tr>
))}
{!drilldownState.rows.length ? (
<tr>
<td className="px-2 py-2 text-gray-500" colSpan={Math.max(drilldownState.columns.length, 1)}>
No source rows loaded.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
<section className="space-y-2">
<h4 className="m-0 font-semibold">Diagnostics</h4>
<button className="rounded border px-2 py-1" onClick={() => void runDiagnostics()}>Run diagnostics</button>
<div className="space-y-1 text-xs text-gray-600 dark:text-gray-300">
{diagnostics?.rows.map((row) => <div key={row.id}>{row.label}: {row.value}</div>) ?? 'No diagnostics yet'}
</div>
<div className="flex flex-wrap gap-1">
{diagnostics?.chips.map((chip) => (
<span
key={chip.id}
className={`rounded border px-2 py-0.5 text-xs ${chip.tone === 'warning' ? 'border-amber-300 text-amber-700' : chip.tone === 'success' ? 'border-emerald-300 text-emerald-700' : 'border-gray-300 text-gray-600'}`}
>
{chip.label}
</span>
))}
</div>
</section>
<section className="space-y-2">
<h4 className="m-0 font-semibold">Remote activity</h4>
<div className="max-h-28 space-y-1 overflow-auto text-xs text-gray-600 dark:text-gray-300">
{activity.length ? activity.map((entry) => (
<div
key={entry.id}
className={entry.tone === 'warning' ? 'text-amber-700' : entry.tone === 'success' ? 'text-emerald-700' : ''}
>
{entry.label}
</div>
)) : 'No remote activity yet.'}
</div>
</section>
</aside>
<div className="grid-shell h-full min-w-0 grow overflow-hidden">
<RevoGrid
ref={gridRef}
className="h-full w-full cell-border"
hideAttribution
range
resize
readonly
colSize={180}
source={[]}
columns={[]}
pivot={pivot}
additionalData={additionalData as any}
theme={isDark() ? 'darkCompact' : 'compact'}
plugins={plugins}
columnTypes={columnTypes}
/>
</div>
</div>
);
}
function getPreset(viewId: string) {
return PIVOT_REMOTE_SAVED_VIEW_PRESETS.find((preset) => preset.viewId === viewId);
}
function getViewName(view: PivotSavedViewRecord) {
return 'name' in view ? view.name ?? '(unnamed)' : '(deleted)';
}
function hasActiveView(views: readonly PivotSavedViewRecord[], viewId: string) {
return views.some((view) => view.viewId === viewId && !('deleted' in view));
}
export default PivotRemoteTools;
import { CommonModule } from '@angular/common';
import { Component, NO_ERRORS_SCHEMA, ViewEncapsulation } from '@angular/core';
import type { OnDestroy, OnInit } from '@angular/core';
import { RevoGrid } from '@revolist/angular-datagrid';
import type { ColumnProp, DataType } from '@revolist/revogrid';
import NumberColumnType from '@revolist/revogrid-column-numeral';
import {
PivotDrilldownController,
PivotPlugin,
createPivotDiagnosticsModel,
deletePivotSavedView,
duplicatePivotSavedView,
loadPivotView,
renamePivotSavedView,
savePivotView,
type PivotConfig,
type PivotDiagnosticsModel,
type PivotDrilldownState,
type PivotSavedViewRecord,
} from '@revolist/revogrid-enterprise';
import { PaginationPlugin, RowOddPlugin } from '@revolist/revogrid-pro';
import { currentTheme } from '../composables/useRandomData';
import {
PIVOT_REMOTE_DRILLDOWN_CELLS,
PIVOT_REMOTE_DRILLDOWN_COLUMNS,
PIVOT_REMOTE_FIELDS_VERSION,
PIVOT_REMOTE_SAVED_VIEW_PRESETS,
PIVOT_REMOTE_VIEW_ID,
createPivotRemoteActivityHooks,
createPivotRemoteDiagnosticsRequest,
createPivotRemoteStore,
serializePivotRemoteToolsLayout,
type PivotRemoteToolsActivity,
withPivotRemoteEngine,
withPivotRemoteToolsUi,
} from '../sys-data/pivot.remote';
const USER_ID = 'portal-demo-user';
@Component({
selector: 'pivot-remote-tools-grid',
standalone: true,
imports: [CommonModule, RevoGrid],
template: `
<div class="flex h-full min-h-[620px] gap-3">
<aside class="w-[320px] shrink-0 space-y-3 overflow-auto border-r border-gray-200 pr-3 text-sm dark:border-gray-700">
<h3 class="m-0 text-base font-semibold">Remote Pivot tools</h3>
<p class="m-0 text-xs text-gray-500">{{ message }}</p>
<section class="space-y-2">
<h4 class="m-0 font-semibold">Saved layouts</h4>
<select class="w-full rounded border px-2 py-1 text-xs dark:border-gray-700 dark:bg-gray-900" [value]="activeViewId" (change)="selectView($event)">
<option *ngFor="let preset of presets" [value]="preset.viewId">{{ preset.name }}</option>
<option *ngFor="let view of customViews" [value]="view.viewId">{{ getViewName(view) }}</option>
</select>
<p class="m-0 text-xs text-gray-500">{{ activePreset?.description ?? 'Saved field layout from the grid panel.' }}</p>
<div class="flex flex-wrap gap-1">
<button class="rounded border px-2 py-1" (click)="saveView()">Save current layout</button>
<button class="rounded border px-2 py-1" (click)="renameView()">Rename</button>
<button class="rounded border px-2 py-1" (click)="duplicateView()">Duplicate</button>
<button class="rounded border px-2 py-1" (click)="deleteView()">Delete</button>
</div>
<div class="max-h-24 space-y-1 overflow-auto text-xs text-gray-600 dark:text-gray-300">
<div *ngFor="let view of activeViews">{{ view.viewId }}: {{ getViewName(view) }}</div>
<div *ngIf="!activeViews.length">Save a preset to create a user-owned view.</div>
</div>
</section>
<section class="space-y-2">
<h4 class="m-0 font-semibold">Drilldown</h4>
<select class="w-full rounded border px-2 py-1 text-xs dark:border-gray-700 dark:bg-gray-900" [value]="activeDrilldownId" (change)="selectDrilldown($event)">
<option *ngFor="let item of drilldownCells" [value]="item.id">{{ item.label }}</option>
</select>
<div class="flex flex-wrap gap-1">
<button class="rounded border px-2 py-1" (click)="loadFacts()">Load source rows</button>
<button class="rounded border px-2 py-1" (click)="resetDrilldown()">Reset</button>
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ drilldownState.status }} - {{ drilldownState.rows.length }} of {{ drilldownState.totalCount }} rows - {{ drilldownState.columns.length }} columns
</div>
<div class="max-h-36 overflow-auto rounded border border-gray-200 dark:border-gray-700">
<table class="w-full border-collapse text-xs">
<thead>
<tr>
<th *ngFor="let column of drilldownState.columns" class="border-b px-2 py-1 text-left font-semibold dark:border-gray-700">
{{ column.name ?? column.prop }}
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of drilldownState.rows">
<td *ngFor="let column of drilldownState.columns" class="border-b px-2 py-1 dark:border-gray-800">
{{ getCellValue(row, column.prop) }}
</td>
</tr>
<tr *ngIf="!drilldownState.rows.length">
<td class="px-2 py-2 text-gray-500" [attr.colspan]="Math.max(drilldownState.columns.length, 1)">
No source rows loaded.
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="space-y-2">
<h4 class="m-0 font-semibold">Diagnostics</h4>
<button class="rounded border px-2 py-1" (click)="runDiagnostics()">Run diagnostics</button>
<div class="space-y-1 text-xs text-gray-600 dark:text-gray-300">
<div *ngFor="let row of diagnostics?.rows ?? []">{{ row.label }}: {{ row.value }}</div>
<div *ngIf="!diagnostics">No diagnostics yet</div>
</div>
<div class="flex flex-wrap gap-1">
<span
*ngFor="let chip of diagnostics?.chips ?? []"
class="rounded border px-2 py-0.5 text-xs"
[ngClass]="chipClass(chip.tone)"
>
{{ chip.label }}
</span>
</div>
</section>
<section class="space-y-2">
<h4 class="m-0 font-semibold">Remote activity</h4>
<div class="max-h-28 space-y-1 overflow-auto text-xs text-gray-600 dark:text-gray-300">
<div *ngFor="let entry of activity" [ngClass]="activityClass(entry.tone)">{{ entry.label }}</div>
<div *ngIf="!activity.length">No remote activity yet.</div>
</div>
</section>
</aside>
<div class="grid-shell h-full min-w-0 grow overflow-hidden">
<revo-grid
class="h-full w-full cell-border"
[hideAttribution]="true"
[range]="true"
[resize]="true"
[readonly]="true"
[colSize]="180"
[source]="[]"
[pivot]="pivot"
[pagination]="additionalData.pagination"
[theme]="theme"
[plugins]="plugins"
[columnTypes]="columnTypes"
(pivot-config-update)="configUpdate($event)"
></revo-grid>
</div>
</div>
`,
// Allows Angular demos to bind RevoGrid plugin props that are not wrapper inputs.
schemas: [NO_ERRORS_SCHEMA],
encapsulation: ViewEncapsulation.None,
})
export class PivotRemoteToolsGridComponent implements OnInit, OnDestroy {
readonly Math = Math;
readonly presets = PIVOT_REMOTE_SAVED_VIEW_PRESETS;
readonly drilldownCells = PIVOT_REMOTE_DRILLDOWN_CELLS;
activity: PivotRemoteToolsActivity[] = [];
private store = createPivotRemoteStore(createPivotRemoteActivityHooks((entry) => {
this.activity = [entry, ...this.activity].slice(0, 8);
}));
private drilldown = new PivotDrilldownController(this.store, { defaultLimit: 5 });
private unsubscribe: () => void = () => undefined;
private currentLayoutConfig: Partial<PivotConfig> = PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].config;
theme = currentTheme().isDark() ? 'darkCompact' : 'compact';
diagnostics: PivotDiagnosticsModel | null = null;
drilldownState: PivotDrilldownState = this.drilldown.getState();
views: PivotSavedViewRecord[] = [];
activeViewId = PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].viewId;
viewName = PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].name;
activeDrilldownId: string = PIVOT_REMOTE_DRILLDOWN_CELLS[0].id;
message = 'Use the grid field panel to change dimensions, then save the layout.';
columnTypes = {
currency: new NumberColumnType('$0,0.00'),
};
plugins = [PivotPlugin, PaginationPlugin, RowOddPlugin];
pivot = this.createGridPivot(PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].config);
additionalData = {
pagination: {
itemsPerPage: 3,
initialPage: 0,
total: 0,
},
};
get activeViews() {
return this.views.filter((view) => !('deleted' in view));
}
get customViews() {
return this.activeViews.filter((view) => !this.getPreset(view.viewId));
}
get activePreset() {
return this.getPreset(this.activeViewId);
}
get activeDrilldown() {
return this.drilldownCells.find((item) => item.id === this.activeDrilldownId) ?? this.drilldownCells[0];
}
ngOnInit() {
this.unsubscribe = this.drilldown.subscribe((state) => {
this.drilldownState = state;
});
void this.runDiagnostics();
}
ngOnDestroy() {
this.unsubscribe();
this.drilldown.abort();
}
async selectView(event: Event) {
const viewId = (event.target as HTMLSelectElement).value;
this.activeViewId = viewId;
const preset = this.getPreset(viewId);
if (preset) {
this.viewName = preset.name;
this.currentLayoutConfig = preset.config;
this.pivot = this.createGridPivot(preset.config);
this.message = `Loaded ${preset.name} preset into the grid.`;
return;
}
const saved = await loadPivotView(this.store, { userId: USER_ID, viewId });
this.viewName = saved.name ?? saved.viewId;
this.currentLayoutConfig = saved.config;
this.pivot = this.createGridPivot(saved.config);
this.message = `Loaded ${saved.name ?? saved.viewId} into the grid.`;
}
selectDrilldown(event: Event) {
this.activeDrilldownId = (event.target as HTMLSelectElement).value;
}
async runDiagnostics() {
const response = await this.store.load(createPivotRemoteDiagnosticsRequest(this.getCurrentLayoutConfig()));
this.diagnostics = createPivotDiagnosticsModel(response, {
formatGeneratedAt: (value) => new Date(value).toLocaleTimeString(),
});
this.message = 'Diagnostics refreshed for the selected layout.';
}
async loadFacts() {
await this.drilldown.load({
viewId: PIVOT_REMOTE_VIEW_ID,
fieldsVersion: PIVOT_REMOTE_FIELDS_VERSION,
cell: this.activeDrilldown.cell,
customColumns: PIVOT_REMOTE_DRILLDOWN_COLUMNS,
});
this.message = `Loaded source rows for ${this.activeDrilldown.label}.`;
}
resetDrilldown() {
this.drilldown.reset();
this.message = 'Drilldown reset.';
}
async saveView() {
const preset = this.getPreset(this.activeViewId);
const targetViewId = preset ? `custom-layout-${this.activeViews.length + 1}` : this.activeViewId;
const targetName = preset ? `Saved Layout ${this.activeViews.length + 1}` : this.viewName;
const saved = await savePivotView(this.store, {
userId: USER_ID,
viewId: targetViewId,
name: targetName,
config: this.getCurrentLayoutConfig(),
});
this.views = [...this.views.filter((view) => view.viewId !== saved.viewId), saved];
this.activeViewId = saved.viewId;
this.viewName = saved.name ?? saved.viewId;
this.currentLayoutConfig = saved.config;
this.pivot = this.createGridPivot(saved.config);
this.message = `Saved ${saved.name ?? saved.viewId}.`;
}
renameView() {
if (!hasActiveView(this.views, this.activeViewId)) {
this.message = 'Save a view before renaming.';
return;
}
const nextName = `${this.viewName} Renamed`;
this.views = renamePivotSavedView(this.views, { userId: USER_ID, viewId: this.activeViewId, name: nextName });
this.viewName = nextName;
this.message = `Renamed ${this.activeViewId}.`;
}
duplicateView() {
if (!hasActiveView(this.views, this.activeViewId)) {
this.message = 'Save a view before duplicating.';
return;
}
const newViewId = `${this.activeViewId}-copy-${this.activeViews.length + 1}`;
const nextName = `${this.viewName} Copy`;
this.views = duplicatePivotSavedView(this.views, {
userId: USER_ID,
viewId: this.activeViewId,
newViewId,
name: nextName,
});
this.activeViewId = newViewId;
this.viewName = nextName;
const duplicate = this.activeViews.find((view) => view.viewId === newViewId);
if (duplicate && 'config' in duplicate) {
this.currentLayoutConfig = duplicate.config;
this.pivot = this.createGridPivot(duplicate.config);
}
this.message = `Duplicated as ${newViewId}.`;
}
deleteView() {
if (!hasActiveView(this.views, this.activeViewId)) {
this.message = 'Save a view before deleting.';
return;
}
this.views = deletePivotSavedView(this.views, { userId: USER_ID, viewId: this.activeViewId });
this.activeViewId = PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].viewId;
this.viewName = PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].name;
this.currentLayoutConfig = PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].config;
this.pivot = this.createGridPivot(PIVOT_REMOTE_SAVED_VIEW_PRESETS[0].config);
this.message = 'Deleted active view.';
}
configUpdate(event: CustomEvent<PivotConfig>) {
this.currentLayoutConfig = serializePivotRemoteToolsLayout(event.detail ?? this.currentLayoutConfig);
this.pivot = this.createGridPivot(this.currentLayoutConfig);
this.message = 'Layout changed in the field panel. Save it to keep this arrangement.';
}
getCurrentLayoutConfig(): Partial<PivotConfig> {
return serializePivotRemoteToolsLayout(this.currentLayoutConfig);
}
private createGridPivot(config: Partial<PivotConfig>) {
return withPivotRemoteEngine(withPivotRemoteToolsUi(config), this.store);
}
getPreset(viewId: string) {
return PIVOT_REMOTE_SAVED_VIEW_PRESETS.find((preset) => preset.viewId === viewId);
}
getViewName(view: PivotSavedViewRecord) {
return 'name' in view ? view.name ?? '(unnamed)' : '(deleted)';
}
getCellValue(row: DataType, prop: ColumnProp) {
return row[prop] ?? '';
}
chipClass(tone: string) {
if (tone === 'warning') return 'border-amber-300 text-amber-700';
if (tone === 'success') return 'border-emerald-300 text-emerald-700';
return 'border-gray-300 text-gray-600';
}
activityClass(tone: string) {
if (tone === 'warning') return 'text-amber-700';
if (tone === 'success') return 'text-emerald-700';
return '';
}
}
function hasActiveView(views: readonly PivotSavedViewRecord[], viewId: string) {
return views.some((view) => view.viewId === viewId && !('deleted' in view));
}
import type { DataType } from '@revolist/revogrid';
import {
ClientPivotEngineAdapter,
HttpPivotRemoteStore,
type PivotConfig,
type PivotDrilldownCellRef,
type PivotDrilldownRequest,
type PivotDrilldownResponse,
type PivotLoadRequest,
type PivotLoadResponse,
type PivotRemoteStore,
type PivotRemoteStoreHooks,
type PivotSummaryType,
type PivotStateResponse,
} from '@revolist/revogrid-enterprise';
/**
* Sample source rows used by the portal remote Pivot demo. The grid itself only
* sees the visible analytical block returned by the mocked remote API.
*/
export const PIVOT_REMOTE_ROWS: DataType[] = [
{ region: 'North', rep: 'Jane', year: 2024, quarter: 'Q1', sales: 32, margin: 8 },
{ region: 'North', rep: 'Jane', year: 2024, quarter: 'Q2', sales: 28, margin: 7 },
{ region: 'North', rep: 'John', year: 2024, quarter: 'Q1', sales: 24, margin: 5 },
{ region: 'North', rep: 'John', year: 2024, quarter: 'Q2', sales: 27, margin: 6 },
{ region: 'South', rep: 'Alice', year: 2024, quarter: 'Q1', sales: 22, margin: 4 },
{ region: 'South', rep: 'Alice', year: 2024, quarter: 'Q2', sales: 25, margin: 5 },
{ region: 'South', rep: 'Bob', year: 2024, quarter: 'Q1', sales: 18, margin: 3 },
{ region: 'South', rep: 'Bob', year: 2024, quarter: 'Q2', sales: 20, margin: 4 },
{ region: 'West', rep: 'Mia', year: 2025, quarter: 'Q1', sales: 29, margin: 6 },
{ region: 'West', rep: 'Mia', year: 2025, quarter: 'Q2', sales: 34, margin: 9 },
{ region: 'West', rep: 'Noah', year: 2025, quarter: 'Q1', sales: 21, margin: 4 },
{ region: 'West', rep: 'Noah', year: 2025, quarter: 'Q2', sales: 23, margin: 5 },
];
export const PIVOT_REMOTE_VIEW_ID = 'portal-sales-remote';
export const PIVOT_REMOTE_FIELDS_VERSION = 'portal:remote:v1';
/**
* Base Pivot configuration shared by all portal remote examples. The remote
* store is attached by each framework wrapper at runtime.
*/
export const PIVOT_REMOTE_CONFIG: Omit<PivotConfig, 'engine'> = {
dimensions: [
{ prop: 'region', name: 'Region' },
{ prop: 'rep', name: 'Rep' },
{ prop: 'year', name: 'Year' },
{ prop: 'quarter', name: 'Quarter' },
{ prop: 'sales', name: 'Sales' },
{ prop: 'margin', name: 'Margin' },
],
rows: ['region', 'rep'],
columns: ['year', 'quarter'],
values: [{ prop: 'sales', aggregator: 'sum' }],
filters: ['margin'],
totals: { grandTotal: true, subtotals: true },
collapsed: true,
columnCollapse: { collapsed: true },
groupAggregations: true,
};
export const PIVOT_REMOTE_SAVED_VIEW_PRESETS: Array<{
viewId: string;
name: string;
description: string;
config: Omit<PivotConfig, 'engine'>;
}> = [
{
viewId: 'north-sales-by-rep',
name: 'North Sales by Rep',
description: 'Region and rep rows with quarterly sales columns.',
config: {
...PIVOT_REMOTE_CONFIG,
rows: ['region', 'rep'],
columns: ['year', 'quarter'],
values: [{ prop: 'sales', aggregator: 'sum' }],
filters: ['margin'],
expanded: { North: true },
},
},
{
viewId: 'quarterly-region-sales',
name: 'Quarterly Region Sales',
description: 'Region rows with year and quarter sales columns.',
config: {
...PIVOT_REMOTE_CONFIG,
rows: ['region'],
columns: ['year', 'quarter'],
values: [{ prop: 'sales', aggregator: 'sum' }],
filters: ['rep', 'margin'],
expanded: {},
},
},
{
viewId: 'margin-by-region',
name: 'Margin by Region',
description: 'Region rows with yearly margin totals.',
config: {
...PIVOT_REMOTE_CONFIG,
rows: ['region'],
columns: ['year'],
values: [{ prop: 'margin', aggregator: 'sum' }],
filters: ['rep', 'quarter', 'sales'],
expanded: {},
},
},
];
export const PIVOT_REMOTE_DRILLDOWN_CELLS: Array<{
id: string;
label: string;
cell: PivotDrilldownCellRef;
}> = [
{
id: 'north-jane-2024-q1',
label: 'North / Jane / 2024 Q1',
cell: { rowPath: ['North', 'Jane'], columnPath: [2024, 'Q1'] },
},
{
id: 'south-alice-2024-q2',
label: 'South / Alice / 2024 Q2',
cell: { rowPath: ['South', 'Alice'], columnPath: [2024, 'Q2'] },
},
{
id: 'west-mia-2025-q2',
label: 'West / Mia / 2025 Q2',
cell: { rowPath: ['West', 'Mia'], columnPath: [2025, 'Q2'] },
},
];
export const PIVOT_REMOTE_DRILLDOWN_COLUMNS = ['region', 'rep', 'year', 'quarter', 'sales', 'margin'];
export interface PivotRemoteToolsActivity {
id: string;
label: string;
tone: 'neutral' | 'success' | 'warning';
}
type MockStateMap = Map<string, Record<string, unknown>>;
/**
* Builds a mock fetch implementation that exposes the same `/api/pivot/*`
* contract used by the real `HttpPivotRemoteStore`.
*/
export function createMockPivotFetch(
rows: DataType[] = PIVOT_REMOTE_ROWS,
baseConfig: Omit<PivotConfig, 'engine'> = PIVOT_REMOTE_CONFIG,
) {
const state = new Map<string, Record<string, unknown>>() as MockStateMap;
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === 'string'
? new URL(input, 'http://portal.local')
: new URL(input.toString(), 'http://portal.local');
const pathname = url.pathname;
const method = init?.method ?? 'GET';
if (pathname === '/api/pivot/load' && method === 'POST') {
const request = JSON.parse(String(init?.body ?? '{}')) as PivotLoadRequest;
const adapter = new ClientPivotEngineAdapter(createConfigFromLoadRequest(request, baseConfig), rows);
const response = await adapter.load(request);
return jsonResponse(response);
}
if (pathname === '/api/pivot/drilldown' && method === 'POST') {
const request = JSON.parse(String(init?.body ?? '{}')) as PivotDrilldownRequest;
const response = createMockDrilldownResponse(request, rows);
return jsonResponse(response);
}
if (pathname === '/api/pivot/state/save' && method === 'POST') {
const request = JSON.parse(String(init?.body ?? '{}')) as {
userId: string;
viewId: string;
state: Record<string, unknown>;
};
state.set(`${request.userId}:${request.viewId}`, request.state);
return new Response(null, { status: 204 });
}
if (pathname.startsWith('/api/pivot/state/') && method === 'GET') {
const [, , , userId, viewId] = pathname.split('/');
const saved = state.get(`${userId}:${viewId}`) ?? { expanded: { North: true } };
const response: PivotStateResponse = {
userId,
viewId,
state: saved,
version: 1,
};
return jsonResponse(response);
}
if (pathname === '/api/pivot/cache/invalidate' && method === 'POST') {
state.clear();
return new Response(null, { status: 204 });
}
return jsonResponse({ message: 'Not found' }, 404);
};
}
/**
* Creates the framework-agnostic remote store used by the live portal demos.
*/
export function createPivotRemoteStore(hooks?: PivotRemoteStoreHooks): PivotRemoteStore {
return new HttpPivotRemoteStore({
baseUrl: '',
tenantId: 'portal-demo',
datasetWatermark: 'portal-seed-v1',
authProvider: async () => ({
Authorization: 'Bearer portal-demo-token',
'x-tenant-id': 'portal-demo',
}),
fetchImpl: createMockPivotFetch() as typeof fetch,
hooks,
});
}
export function createPivotRemoteActivityHooks(
push: (activity: PivotRemoteToolsActivity) => void,
): PivotRemoteStoreHooks {
let seq = 0;
const next = (
label: string,
tone: PivotRemoteToolsActivity['tone'] = 'neutral',
): PivotRemoteToolsActivity => ({
id: `pivot-remote-activity-${++seq}`,
label,
tone,
});
return {
requestStarted: ({ type }) => push(next(`${type}: started`)),
requestSucceeded: ({ type, cacheStatus }) => {
push(next(`${type}: succeeded${cacheStatus ? ` (${cacheStatus})` : ''}`, 'success'));
},
requestFailed: ({ type }) => push(next(`${type}: failed`, 'warning')),
cacheStatusChanged: ({ cacheStatus }) => push(next(`cache: ${cacheStatus}`, 'neutral')),
};
}
export function withPivotRemoteEngine(
config: Partial<PivotConfig>,
remoteStore: PivotRemoteStore,
): Partial<PivotConfig> {
return {
...config,
engine: {
mode: 'server',
remoteStore,
viewId: PIVOT_REMOTE_VIEW_ID,
fieldsVersion: PIVOT_REMOTE_FIELDS_VERSION,
rowAxis: { offset: 0, expandedPaths: [['North']] },
columnAxis: { offset: 0, limit: 8 },
},
};
}
export function withPivotRemoteToolsUi(config: Partial<PivotConfig>): Partial<PivotConfig> {
return {
...config,
fieldPanel: {
visible: true,
allowFieldDragging: true,
allowFieldRemoving: true,
showDataFields: true,
showRowFields: true,
showColumnFields: true,
showFilterFields: true,
...(typeof config.fieldPanel === 'object' ? config.fieldPanel : {}),
},
};
}
export function serializePivotRemoteToolsLayout(config: Partial<PivotConfig>): Partial<PivotConfig> {
const serializable = { ...config };
delete serializable.engine;
delete serializable.mountTo;
return {
...serializable,
fieldPanel: {
visible: true,
allowFieldDragging: true,
allowFieldRemoving: true,
showDataFields: true,
showRowFields: true,
showColumnFields: true,
showFilterFields: true,
...(typeof serializable.fieldPanel === 'object' ? serializable.fieldPanel : {}),
},
};
}
export function createPivotRemoteDiagnosticsRequest(
config: Partial<PivotConfig> = PIVOT_REMOTE_CONFIG,
): PivotLoadRequest {
return {
requestId: `pivot-tools-${Date.now()}`,
viewId: PIVOT_REMOTE_VIEW_ID,
fieldsVersion: PIVOT_REMOTE_FIELDS_VERSION,
loadOptions: {
rows: (config.rows ?? []).map((selector) => ({ selector: String(selector) })),
columns: (config.columns ?? []).map((selector) => ({ selector: String(selector) })),
totalSummary: (config.values ?? []).map((value) => ({
selector: String(value.prop),
summaryType: value.aggregator as PivotSummaryType,
})),
groupSummary: (config.values ?? []).map((value) => ({
selector: String(value.prop),
summaryType: value.aggregator as PivotSummaryType,
})),
},
viewport: {
rowAxis: { offset: 0, limit: 6, expandedPaths: [['North']] },
columnAxis: { offset: 0, limit: 8 },
},
uiState: {
collapsedByDefault: config.collapsed ?? true,
rowHeaderLayout: 'tree',
},
};
}
function createConfigFromLoadRequest(
request: PivotLoadRequest,
baseConfig: Omit<PivotConfig, 'engine'>,
): Omit<PivotConfig, 'engine'> {
const values = request.loadOptions.totalSummary?.length
? request.loadOptions.totalSummary.map((summary) => ({
prop: summary.selector,
aggregator: summary.summaryType,
}))
: baseConfig.values;
return {
...baseConfig,
rows: request.loadOptions.rows?.map((row) => row.selector) ?? baseConfig.rows,
columns: request.loadOptions.columns?.map((column) => column.selector) ?? baseConfig.columns,
values,
collapsed: request.uiState?.collapsedByDefault ?? baseConfig.collapsed,
};
}
function createMockDrilldownResponse(
request: PivotDrilldownRequest,
rows: DataType[],
): PivotDrilldownResponse {
const [region, rep] = request.cell.rowPath;
const [year, quarter] = request.cell.columnPath;
const filtered = rows.filter((row) => {
return (region ? row.region === region : true)
&& (rep ? row.rep === rep : true)
&& (year ? row.year === year : true)
&& (quarter ? row.quarter === quarter : true);
});
const selectedColumns = request.customColumns?.length
? request.customColumns
: ['region', 'rep', 'year', 'quarter', 'sales'];
const page = filtered
.slice(request.offset, request.offset + request.limit)
.map((row) => Object.fromEntries(selectedColumns.map((column) => [column, row[column]])));
return {
requestId: request.requestId,
data: page,
totalCount: filtered.length,
meta: {
cacheStatus: 'bypass',
},
};
}
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: {
'content-type': 'application/json',
},
});
}