Skip to content

Commit 2b1d1a8

Browse files
committed
feat: added single page for audit rows
1 parent 3eae3b1 commit 2b1d1a8

61 files changed

Lines changed: 6986 additions & 3753 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

PRODUCT.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Product
2+
3+
## Register
4+
5+
product
6+
7+
## Users
8+
9+
Superposition Embeddable UI is used by developers, platform operators, product engineers, and workspace administrators who need to inspect and manage configuration, dimensions, overrides, and audit activity from within a host application.
10+
11+
## Product Purpose
12+
13+
The library provides embeddable admin surfaces for Superposition configuration management. Success means a host app can mount focused, trustworthy workflows that feel native to Juspay product tooling while remaining easy to scope, theme, and integrate.
14+
15+
## Brand Personality
16+
17+
Precise, composed, operational. The UI should feel like a dependable control surface for high-stakes configuration work, not a decorative dashboard.
18+
19+
## Anti-references
20+
21+
Avoid bespoke controls that diverge from Blend, marketing-style layouts, oversized empty states, heavy gradients, nested cards, and dense inline styling that makes the interface feel detached from the design system.
22+
23+
## Design Principles
24+
25+
- Blend first: use Blend components, tokens, states, and spacing as the primary interface vocabulary.
26+
- Keep operators in flow: filters, tables, forms, and actions should be predictable, compact, and quick to scan.
27+
- Make scope visible: workspace, boundary context, feature gates, and read-only limits should be clear without adding visual noise.
28+
- Favor structured detail: use consistent key-value rows, tags, and tables for configuration data instead of ad hoc text blocks.
29+
- Preserve host control: redesigns must keep the embeddable API, custom modal hooks, theming hooks, and scoped behavior intact.
30+
31+
## Accessibility & Inclusion
32+
33+
Target accessible defaults through Blend primitives, keyboard-friendly controls, visible focus states, readable contrast, and layouts that remain usable in constrained embedded containers.

README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,12 @@ The host app only needs to provide a `config` object.
5353
- `scope.locked` (optional): keeps the UI inside that bounded slice.
5454
- `strict` (optional): prevents extra boundary context and uses exact context matching for override lists.
5555
- `features` (optional): which screens are allowed to render. `[]` renders no feature UI.
56-
- `capabilities` (optional): per-feature action switches such as create, delete, ramp, and execute.
56+
- `capabilities` (optional): per-feature action switches for create, update, and delete.
5757
- `filters.defaultConfigPrefix` (optional): restricts default config keys and override values.
5858
- `theme` (optional): color, typography, spacing, and radius overrides.
5959
- `layout` (optional): host-controlled shell, modal, and alert sizing.
6060
- `ui` (optional): host-owned notifications, confirmation, layering, and boundary-filter controls.
61+
- `ui.featureControls` (optional): host-owned UI edit toggles for embeddable feature pages.
6162
- `messages` (optional): host overrides for visible SDK copy.
6263

6364
The embeddable UI currently uses its own fetch-based REST client. It does not call a host-installed Superposition SDK directly. If your host app already talks to Superposition through its own backend or SDK, expose or proxy those REST endpoints from the host and point `apiBaseUrl` at that proxy.
@@ -179,6 +180,10 @@ The embeddable UI currently uses its own fetch-based REST client. It does not ca
179180
},
180181
"ui": {
181182
"showBoundaryFilter": false,
183+
"featureControls": {
184+
"config": { "editable": true },
185+
"dimensions": { "editable": true }
186+
},
182187
"modalZIndex": 1200,
183188
"alertZIndex": 1200
184189
},
@@ -211,7 +216,7 @@ strict: true,
211216
```
212217

213218
If you leave `scope.context` out, the overrides UI is view-only unless
214-
`capabilities.overrides.editContext` is enabled. The backend is responsible
219+
`capabilities.overrides.update` is enabled. The backend is responsible
215220
for authorizing create and edit actions — if the token lacks permission,
216221
the backend will reject the request.
217222

@@ -226,6 +231,9 @@ the List Contexts API as `dimension[...]` query params; strict mode sends
226231
`readOnly` is still the fastest way to disable all mutating actions. Use
227232
`capabilities` when the host needs more precise control.
228233

234+
When a feature appears in `capabilities`, omitted actions default to `false`.
235+
That makes `capabilities` an explicit allowlist rather than a partial override.
236+
229237
For overrides, create and update actions are controlled by `capabilities`.
230238
The backend enforces authorization — if the request lacks valid credentials,
231239
it will be rejected.
@@ -251,6 +259,10 @@ ui: {
251259
hostModal.render({ title, children, footer, onClose }),
252260
portalContainer: "#host-overlays",
253261
showBoundaryFilter: false,
262+
featureControls: {
263+
config: { editable: true },
264+
dimensions: { editable: true },
265+
},
254266
},
255267
```
256268

__tests__/api/audit-logs.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ describe("auditLogsApi", () => {
4141
tables: ["contexts", "default_configs"],
4242
action: ["UPDATE"],
4343
username: "alice",
44+
dimension_params: {
45+
"dimension[cid]": "swiggy",
46+
"dimension[merchant_id]": "merchant_123",
47+
},
4448
sort_by: "asc",
4549
},
4650
);
@@ -52,8 +56,46 @@ describe("auditLogsApi", () => {
5256
expect(url).toContain("table=contexts,default_configs");
5357
expect(url).toContain("action=UPDATE");
5458
expect(url).toContain("username=alice");
59+
expect(url).toContain("dimension[cid]=swiggy");
60+
expect(url).toContain("dimension[merchant_id]=merchant_123");
5561
expect(url).toContain("sort_by=asc");
5662
expect(url).toContain("from_date=2024-05-01T00%3A00%3A00.000Z");
5763
expect(url).toContain("to_date=2024-05-02T23%3A59%3A59.999Z");
5864
});
65+
66+
it("normalizes missing audit data to an empty page", async () => {
67+
const api = auditLogsApi(client);
68+
mockFetch.mockResolvedValueOnce({
69+
ok: true,
70+
status: 200,
71+
headers: new Headers({ "content-length": "20" }),
72+
json: () => Promise.resolve({ total_pages: 0, total_items: 0 }),
73+
});
74+
75+
await expect(api.list()).resolves.toEqual({
76+
total_pages: 0,
77+
total_items: 0,
78+
data: [],
79+
});
80+
});
81+
82+
it("returns an empty page when backend reports no audit records", async () => {
83+
const api = auditLogsApi(client);
84+
mockFetch.mockResolvedValueOnce({
85+
ok: false,
86+
status: 404,
87+
text: () =>
88+
Promise.resolve(
89+
JSON.stringify({
90+
message: "No records found. Please refine or correct your search parameters",
91+
}),
92+
),
93+
});
94+
95+
await expect(api.list()).resolves.toEqual({
96+
total_pages: 0,
97+
total_items: 0,
98+
data: [],
99+
});
100+
});
59101
});

__tests__/api/resolve.test.ts

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,59 @@ describe("resolveApi", () => {
2424
vi.restoreAllMocks();
2525
});
2626

27-
it("calls GET /config/resolve with dimension query params", async () => {
27+
it("calls POST /config/resolve with context in the request body", async () => {
2828
const api = resolveApi(client);
2929

30-
await api.resolve({ region: "ap-south-1", city: "blr" }, "DEEP");
30+
await api.resolve({ region: "ap-south-1", city: "blr" }, "MERGE");
31+
32+
const [url, init] = mockFetch.mock.calls[0];
33+
expect(url).toBe("https://superposition.test/config/resolve?merge_strategy=MERGE");
34+
expect(init.method).toBe("POST");
35+
expect(init.body).toBe(
36+
JSON.stringify({ context: { region: "ap-south-1", city: "blr" } }),
37+
);
38+
});
39+
40+
it("calls POST /config/resolve/detailed and normalizes detailed config rows", async () => {
41+
const api = resolveApi(client);
42+
mockFetch.mockResolvedValueOnce({
43+
ok: true,
44+
status: 200,
45+
headers: new Headers({ "content-length": "300" }),
46+
json: () =>
47+
Promise.resolve({
48+
"checkout.title": {
49+
value: "Fast Checkout",
50+
schema: { type: "string" },
51+
description: "Checkout title",
52+
},
53+
}),
54+
});
55+
56+
await expect(
57+
api.resolveDetailed(
58+
{ region: "ap-south-1" },
59+
{ prefix: ["checkout."], mergeStrategy: "MERGE" },
60+
),
61+
).resolves.toMatchObject({
62+
total_pages: 1,
63+
total_items: 1,
64+
data: [
65+
{
66+
key: "checkout.title",
67+
value: "Fast Checkout",
68+
schema: { type: "string" },
69+
description: "Checkout title",
70+
},
71+
],
72+
});
3173

3274
const [url, init] = mockFetch.mock.calls[0];
3375
expect(url).toBe(
34-
"https://superposition.test/config/resolve?dimension[region]=ap-south-1&dimension[city]=blr&merge_strategy=DEEP",
76+
"https://superposition.test/config/resolve/detailed?prefix=checkout.&merge_strategy=MERGE",
3577
);
36-
expect(init.method).toBe("GET");
37-
expect(init.body).toBeUndefined();
78+
expect(init.method).toBe("POST");
79+
expect(init.body).toBe(JSON.stringify({ context: { region: "ap-south-1" } }));
3880
});
3981

4082
it("calls GET /config for the cached config contract", async () => {
@@ -58,9 +100,7 @@ describe("resolveApi", () => {
58100
});
59101

60102
const [url, init] = mockFetch.mock.calls[0];
61-
expect(url).toBe(
62-
"https://superposition.test/config?dimension[region]=ap-south-1",
63-
);
103+
expect(url).toBe("https://superposition.test/config?dimension[region]=ap-south-1");
64104
expect(init.method).toBe("GET");
65105
});
66106
});

__tests__/components/Table.test.tsx

Lines changed: 117 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { describe, it, expect, vi } from "vitest";
22
import { render, screen, fireEvent } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import type { FormEvent } from "react";
35
import { resolveTableSerialNumberProps, Table } from "../../src/components/Table";
6+
import { SuperpositionUIProvider } from "../../src/providers/SuperpositionUIProvider";
47

58
interface Row {
69
id: string;
@@ -30,19 +33,10 @@ describe("Table", () => {
3033
expect(screen.getByText("25 years")).toBeDefined();
3134
});
3235

33-
it("shows empty message when no data", () => {
34-
render(
35-
<Table
36-
columns={columns}
37-
data={[]}
38-
keyExtractor={(r: Row) => r.id}
39-
emptyMessage="Nothing here"
40-
emptyDescription="Add a row to populate this table."
41-
/>,
42-
);
43-
44-
expect(screen.getByText("Nothing here")).toBeDefined();
45-
expect(screen.getByText("Add a row to populate this table.")).toBeDefined();
36+
it("renders with empty data", () => {
37+
expect(() =>
38+
render(<Table columns={columns} data={[]} keyExtractor={(r: Row) => r.id} />),
39+
).not.toThrow();
4640
});
4741

4842
it("shows loading state", () => {
@@ -55,7 +49,7 @@ describe("Table", () => {
5549
/>,
5650
);
5751

58-
expect(screen.getByText("Loading...")).toBeDefined();
52+
expect(screen.getByText("Loading data...")).toBeDefined();
5953
});
6054

6155
it("calls onRowClick when row is clicked", () => {
@@ -91,6 +85,115 @@ describe("Table", () => {
9185
expect(screen.getByText("11").style.textAlign).toBe("left");
9286
});
9387

88+
it("keeps Blend table controls from submitting host forms", async () => {
89+
const user = userEvent.setup();
90+
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
91+
event.preventDefault();
92+
});
93+
const onPageSizeChange = vi.fn();
94+
95+
render(
96+
<form onSubmit={onSubmit}>
97+
<SuperpositionUIProvider
98+
config={{ apiBaseUrl: "/api", orgId: "org", workspace: "ws" }}
99+
>
100+
<Table
101+
columns={columns}
102+
data={data}
103+
keyExtractor={(row) => row.id}
104+
pagination={{
105+
currentPage: 1,
106+
pageSize: 10,
107+
totalRows: 25,
108+
pageSizeOptions: [10, 20],
109+
}}
110+
onPageChange={vi.fn()}
111+
onPageSizeChange={onPageSizeChange}
112+
enableColumnManager
113+
columnManagerAlwaysSelected={["name"]}
114+
/>
115+
</SuperpositionUIProvider>
116+
</form>,
117+
);
118+
119+
await user.click(screen.getByRole("button", { name: "Manage columns" }));
120+
await user.click(
121+
screen.getByRole("button", { name: "Select number of rows per page" }),
122+
);
123+
await user.click(await screen.findByText("20"));
124+
125+
expect(onSubmit).not.toHaveBeenCalled();
126+
expect(onPageSizeChange).toHaveBeenCalledWith(20);
127+
});
128+
129+
it("keeps rows-per-page warning-free on narrow screens", async () => {
130+
const user = userEvent.setup();
131+
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
132+
event.preventDefault();
133+
});
134+
const onPageSizeChange = vi.fn();
135+
const originalInnerWidth = window.innerWidth;
136+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
137+
138+
Object.defineProperty(window, "innerWidth", {
139+
configurable: true,
140+
writable: true,
141+
value: 500,
142+
});
143+
window.dispatchEvent(new Event("resize"));
144+
145+
try {
146+
render(
147+
<form onSubmit={onSubmit}>
148+
<SuperpositionUIProvider
149+
config={{ apiBaseUrl: "/api", orgId: "org", workspace: "ws" }}
150+
>
151+
<Table
152+
columns={columns}
153+
data={data}
154+
keyExtractor={(row) => row.id}
155+
pagination={{
156+
currentPage: 1,
157+
pageSize: 10,
158+
totalRows: 25,
159+
pageSizeOptions: [10, 20],
160+
}}
161+
onPageChange={vi.fn()}
162+
onPageSizeChange={onPageSizeChange}
163+
/>
164+
</SuperpositionUIProvider>
165+
</form>,
166+
);
167+
168+
await user.click(
169+
screen.getByRole("button", { name: "Select number of rows per page" }),
170+
);
171+
await user.click(await screen.findByText("20 / page"));
172+
173+
const consoleMessages = consoleErrorSpy.mock.calls
174+
.flat()
175+
.map((message) => String(message));
176+
177+
expect(onSubmit).not.toHaveBeenCalled();
178+
expect(onPageSizeChange).toHaveBeenCalledWith(20);
179+
expect(
180+
consoleMessages.some(
181+
(message) =>
182+
message.includes("Function components cannot be given refs") ||
183+
message.includes("enableVirtualization"),
184+
),
185+
).toBe(false);
186+
} finally {
187+
consoleErrorSpy.mockRestore();
188+
Object.defineProperty(window, "innerWidth", {
189+
configurable: true,
190+
writable: true,
191+
value: originalInnerWidth,
192+
});
193+
window.dispatchEvent(new Event("resize"));
194+
}
195+
});
196+
94197
it("resolves serial number options from embeddable config", () => {
95198
expect(resolveTableSerialNumberProps({ serialNumber: true }, 21)).toEqual({
96199
showSerialNumber: true,

0 commit comments

Comments
 (0)