Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* PermissionMatrixEditPage `onDirtyChange` contract — the hook the Studio
* Access pillar's unsaved-changes guard hangs on. The pillar keys this page
* per set (`key={current}`), so a rail switch REMOUNTS it; the host can only
* warn before discarding edits if the editor reports its dirty state up:
*
* • clean after the initial load,
* • dirty as soon as a matrix cell changes,
* • clean again after a successful Save (draft becomes the new baseline),
* • reset to false on unmount (a confirmed discard must clear the guard).
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

let clientImpl: any;

vi.mock('./useMetadata', () => ({
useMetadataClient: () => clientImpl,
useMetadataTypes: () => ({
loading: false,
error: null,
entries: [{ type: 'permission', label: 'Permission', allowOrgOverride: true }],
}),
}));

import { PermissionMatrixEditPage } from './PermissionMatrixEditor';

afterEach(cleanup);

interface Server {
set: Record<string, unknown>;
saved: Array<Record<string, unknown>>;
}

function freshServer(): Server {
return {
saved: [],
set: {
name: 'sales_perms',
label: 'Sales',
objects: {
a_account: { allowRead: true, allowCreate: true },
},
fields: {},
},
};
}

function makeClient(server: Server) {
return {
layered: async () => ({
effective: server.set,
code: null,
overlay: null,
overlayScope: null,
}),
getDraft: async () => null,
list: async (type: string) => {
if (type === 'object') return [{ item: { name: 'a_account' } }];
return [];
},
get: async (type: string) =>
type === 'object' ? { fields: [{ name: 'name', label: 'Name' }] } : null,
save: async (_t: string, _n: string, payload: Record<string, unknown>) => {
server.saved.push(payload);
server.set = payload;
return payload;
},
} as any;
}

function renderMatrix(onDirtyChange: (dirty: boolean) => void) {
return render(
<MemoryRouter>
<PermissionMatrixEditPage
type="permission"
name="sales_perms"
packageId="app.a"
onDirtyChange={onDirtyChange}
/>
</MemoryRouter>,
);
}

describe('PermissionMatrixEditPage — onDirtyChange', () => {
it('reports clean on load, dirty on edit, clean again after save', async () => {
const server = freshServer();
clientImpl = makeClient(server);
const onDirtyChange = vi.fn();
renderMatrix(onDirtyChange);

await screen.findByText('a_account');
expect(onDirtyChange).toHaveBeenLastCalledWith(false);

// Any matrix mutation flips the report to dirty.
const row = screen.getByText('a_account').closest('tr')!;
fireEvent.click(within(row).getByRole('button', { name: 'None' }));
expect(onDirtyChange).toHaveBeenLastCalledWith(true);

// Save re-anchors the baseline — clean again, no reload needed.
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(server.saved).toHaveLength(1));
await waitFor(() => expect(onDirtyChange).toHaveBeenLastCalledWith(false));
});

it('resets the report to false on unmount (discard must clear the guard)', async () => {
const server = freshServer();
clientImpl = makeClient(server);
const onDirtyChange = vi.fn();
const view = renderMatrix(onDirtyChange);

await screen.findByText('a_account');
const row = screen.getByText('a_account').closest('tr')!;
fireEvent.click(within(row).getByRole('button', { name: 'None' }));
expect(onDirtyChange).toHaveBeenLastCalledWith(true);

// The host remounts this page (key switch) after a confirmed discard —
// the outgoing instance must not leave the guard armed.
view.unmount();
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,21 @@ export interface PermissionMatrixEditPageProps {
* badge stays a plain read-only chip.
*/
onOpenOwd?: (objectName: string) => void;
/**
* Fires on every unsaved-edit transition (false → true → false). The Studio
* Access pillar keys this page per set (`key={name}`) and swaps it out for
* the OWD overview, so the HOST must know before a surface switch whether a
* remount would discard edits. Reset to `false` on unmount so a discarded
* editor never leaves the host thinking edits are still pending.
*/
onDirtyChange?: (dirty: boolean) => void;
}

/* ────────────────────────────────────────────────────────────────── */
/* Component */
/* ────────────────────────────────────────────────────────────────── */

export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, publishNonce, onOpenOwd }: PermissionMatrixEditPageProps) {
export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved, publishNonce, onOpenOwd, onDirtyChange }: PermissionMatrixEditPageProps) {
const navigate = useNavigate();
const client = useMetadataClient();
// Data adapter (records) — the capability picker reads the live sys_capability
Expand All @@ -187,6 +195,18 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
objects: {},
fields: {},
});
// Snapshot of the last loaded/saved draft — the anchor `isDirty` compares
// against. `null` until the first load lands (nothing to be dirty against).
const baselineRef = React.useRef<string | null>(null);
/** Set the draft AND re-anchor the dirty baseline to it (load + post-save). */
const resetDraftBaseline = React.useCallback((next: PermissionSetDraft) => {
try {
baselineRef.current = JSON.stringify(next);
} catch {
baselineRef.current = null;
}
setDraft(next);
}, []);
const [objects, setObjects] = React.useState<ObjectSummary[]>([]);
const [fieldsByObject, setFieldsByObject] = React.useState<Record<string, FieldSummary[]>>({});
const [expanded, setExpanded] = React.useState<Set<string>>(new Set());
Expand Down Expand Up @@ -269,9 +289,9 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
// Save from a fresh read (see doSave).
if (packageId) {
const sliced = scopePermissionSet(full, list.map((o) => o.name));
setDraft({ ...full, objects: sliced.objects, fields: sliced.fields });
resetDraftBaseline({ ...full, objects: sliced.objects, fields: sliced.fields });
} else {
setDraft(full);
resetDraftBaseline(full);
}
} catch (err: any) {
setError(err?.message ?? String(err));
Expand All @@ -282,7 +302,7 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
return () => {
cancelled = true;
};
}, [client, type, name, packageId, publishNonce]);
}, [client, type, name, packageId, publishNonce, resetDraftBaseline]);

/* ── Lazy-load fields when an object is expanded ─────────── */
async function ensureFields(objectName: string) {
Expand Down Expand Up @@ -348,6 +368,51 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
// don't let it persist.
const [celErrorCount, setCelErrorCount] = React.useState(0);

// Dirty detection — cheap JSON snapshot comparison against the last
// loaded/saved baseline (same approach as ResourceEditPage). Every mutation
// funnels through setDraft (matrix checkboxes, header inputs, capabilities,
// advanced facets), so comparing the draft covers them all.
const isDirty = React.useMemo(() => {
const snap = baselineRef.current;
if (snap == null) return false;
try {
return JSON.stringify(draft) !== snap;
} catch {
return false;
}
}, [draft]);

// Report dirty transitions to the host (see onDirtyChange). Ref-stabilized
// so a non-memoized callback prop doesn't refire the effect; the unmount
// cleanup reports `false` so a deliberately-discarded editor clears the
// host's guard state.
const onDirtyChangeRef = React.useRef(onDirtyChange);
React.useEffect(() => {
onDirtyChangeRef.current = onDirtyChange;
});
React.useEffect(() => {
onDirtyChangeRef.current?.(isDirty);
}, [isDirty]);
React.useEffect(
() => () => {
onDirtyChangeRef.current?.(false);
},
[],
);

// Browser-native "leave site?" prompt on tab close / reload with unsaved
// matrix edits — same guard ResourceEditPage installs.
React.useEffect(() => {
if (!isDirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
// Required for Chrome to actually show the prompt.
e.returnValue = '';
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [isDirty]);

function toggleExpand(objectName: string) {
setExpanded((prev) => {
const next = new Set(prev);
Expand Down Expand Up @@ -449,11 +514,11 @@ export function PermissionMatrixEditPage({ type, name, packageId, onDraftSaved,
if (packageId) {
// The draft is now the pending truth for display; the published baseline
// hasn't moved. Show what we just staged and let the surface count it.
setDraft(toDisplayDraft(toSave));
resetDraftBaseline(toDisplayDraft(toSave));
onDraftSaved?.();
} else {
const lay = await client.layered<PermissionSetDraft>(type, payload.name);
setDraft(toDisplayDraft((lay.effective ?? toSave) as PermissionSetDraft));
resetDraftBaseline(toDisplayDraft((lay.effective ?? toSave) as PermissionSetDraft));
}
setDestructive(null);
} catch (err: any) {
Expand Down
Loading
Loading