Skip to content

Commit c1ea460

Browse files
committed
feat(docs): implement interactive playground components for table and filter management
1 parent cd1cc0f commit c1ea460

File tree

9 files changed

+814
-0
lines changed

9 files changed

+814
-0
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Playground components
2+
3+
Interactive playground for defining table columns, building filter rules with Filter Sphere, and previewing filtered data.
4+
5+
## Parts
6+
7+
- Column builder: add/edit columns (text, number, boolean, select, multi-select, date) and manage per-row values.
8+
- Filter builder: uses `FilterSphereProvider` + `FilterBuilder` to author filters against the dynamic schema.
9+
- Table: renders current rows with applied filters.
10+
11+
## Usage
12+
13+
The playground is used by the docs page at `src/content/docs/reference/playground.mdx`. It is not published as a package export. Components are kept in this folder for clarity.
14+
15+
## Key ideas
16+
17+
- Schema is derived from user-defined columns; filters use `findFilterableFields` via `useFilterSphere`.
18+
- Data lives in a local React state; changing columns resets row values for invalid fields.
19+
- Minimal seeded rows (5–7) keep the UI fast; users can add/remove rows.
20+
21+
## Development
22+
23+
- Components are client-rendered in Astro with `client:load`.
24+
- Keep styling minimal and utility-first (Tailwind classes available in docs).
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { useState } from "react";
2+
import { usePlayground } from "./state";
3+
import type { ColumnOption, ColumnType } from "./types";
4+
5+
const typeOptions: { label: string; value: ColumnType }[] = [
6+
{ label: "Text", value: "text" },
7+
{ label: "Number", value: "number" },
8+
{ label: "Boolean", value: "boolean" },
9+
{ label: "Select", value: "select" },
10+
{ label: "Multi-select", value: "multi-select" },
11+
{ label: "Date", value: "date" },
12+
];
13+
14+
export const ColumnEditor = () => {
15+
const { columns, addColumn, updateColumn, removeColumn } = usePlayground();
16+
const [draft, setDraft] = useState({ name: "", type: "text" as ColumnType });
17+
18+
const onAddOption = (id: string) => {
19+
const col = columns.find((c) => c.id === id);
20+
if (!col) return;
21+
const options: ColumnOption[] = col.options ?? [];
22+
const next = [
23+
...options,
24+
{
25+
id: Math.random().toString(36).slice(2, 8),
26+
label: `Option ${options.length + 1}`,
27+
},
28+
];
29+
updateColumn(id, { options: next });
30+
};
31+
32+
return (
33+
<div className="space-y-3">
34+
<div className="space-y-2">
35+
{columns.map((col) => (
36+
<div
37+
key={col.id}
38+
className="rounded border border-gray-200 dark:border-gray-700 p-2 bg-white dark:bg-gray-900"
39+
>
40+
<div className="flex items-center justify-between gap-2">
41+
<div className="flex items-center gap-2">
42+
<input
43+
className="rounded border border-gray-300 dark:border-gray-600 px-2 py-1 text-sm bg-white dark:bg-gray-950 dark:text-gray-100"
44+
value={col.name}
45+
onChange={(e) =>
46+
updateColumn(col.id, { name: e.target.value })
47+
}
48+
/>
49+
<select
50+
className="rounded border border-gray-300 dark:border-gray-600 px-2 py-1 text-sm bg-white dark:bg-gray-950 dark:text-gray-100"
51+
value={col.type}
52+
onChange={(e) =>
53+
updateColumn(col.id, { type: e.target.value as ColumnType })
54+
}
55+
>
56+
{typeOptions.map((opt) => (
57+
<option key={opt.value} value={opt.value}>
58+
{opt.label}
59+
</option>
60+
))}
61+
</select>
62+
</div>
63+
<button
64+
className="text-xs text-red-600 dark:text-red-400 hover:underline"
65+
onClick={() => removeColumn(col.id)}
66+
>
67+
Remove
68+
</button>
69+
</div>
70+
{(col.type === "select" || col.type === "multi-select") && (
71+
<div className="mt-2 space-y-1">
72+
<div className="flex items-center justify-between text-xs text-gray-500">
73+
<span>Options</span>
74+
<button
75+
className="text-blue-600 dark:text-blue-400 hover:underline"
76+
onClick={() => onAddOption(col.id)}
77+
>
78+
Add option
79+
</button>
80+
</div>
81+
<div className="flex flex-wrap gap-2">
82+
{(col.options ?? []).map((opt, idx) => (
83+
<div key={opt.id} className="flex items-center gap-1">
84+
<input
85+
className="rounded border border-gray-300 dark:border-gray-600 px-2 py-1 text-xs bg-white dark:bg-gray-950 dark:text-gray-100"
86+
value={opt.label}
87+
onChange={(e) => {
88+
const next = [...(col.options ?? [])];
89+
next[idx] = { ...opt, label: e.target.value };
90+
updateColumn(col.id, { options: next });
91+
}}
92+
/>
93+
<button
94+
className="text-[11px] text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
95+
onClick={() => {
96+
const next = (col.options ?? []).filter(
97+
(o) => o.id !== opt.id,
98+
);
99+
updateColumn(col.id, { options: next });
100+
}}
101+
>
102+
103+
</button>
104+
</div>
105+
))}
106+
</div>
107+
</div>
108+
)}
109+
</div>
110+
))}
111+
</div>
112+
113+
<div className="flex items-end gap-2">
114+
<div className="flex flex-col w-40">
115+
<label className="text-xs text-gray-500">Name</label>
116+
<input
117+
className="rounded border border-gray-300 dark:border-gray-600 px-2 py-1 text-sm bg-white dark:bg-gray-950 dark:text-gray-100"
118+
value={draft.name}
119+
onChange={(e) =>
120+
setDraft((prev) => ({ ...prev, name: e.target.value }))
121+
}
122+
placeholder="Column name"
123+
/>
124+
</div>
125+
<div className="flex flex-col w-40">
126+
<label className="text-xs text-gray-500">Type</label>
127+
<select
128+
className="rounded border border-gray-300 dark:border-gray-600 px-2 py-1 text-sm bg-white dark:bg-gray-950 dark:text-gray-100"
129+
value={draft.type}
130+
onChange={(e) =>
131+
setDraft((prev) => ({
132+
...prev,
133+
type: e.target.value as ColumnType,
134+
}))
135+
}
136+
>
137+
{typeOptions.map((opt) => (
138+
<option key={opt.value} value={opt.value}>
139+
{opt.label}
140+
</option>
141+
))}
142+
</select>
143+
</div>
144+
<button
145+
className="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-60 dark:bg-blue-600 dark:hover:bg-blue-500"
146+
disabled={!draft.name.trim()}
147+
onClick={() => {
148+
addColumn({ name: draft.name.trim(), type: draft.type });
149+
setDraft({ name: "", type: "text" });
150+
}}
151+
>
152+
Add column
153+
</button>
154+
</div>
155+
</div>
156+
);
157+
};
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { useMemo } from "react";
2+
import { MultiSelect } from "./multi-select";
3+
import { usePlayground } from "./state";
4+
import type { ColumnDef, Row, RowValues } from "./types";
5+
6+
type Props = {
7+
rows: Row[];
8+
columns: ColumnDef[];
9+
onChangeRow: (rowId: string, values: RowValues) => void;
10+
onRemoveRow: (rowId: string) => void;
11+
};
12+
13+
export const DataTable = ({
14+
rows,
15+
columns,
16+
onChangeRow,
17+
onRemoveRow,
18+
}: Props) => {
19+
const { addRow } = usePlayground();
20+
21+
const columnById = useMemo(
22+
() => Object.fromEntries(columns.map((c) => [c.id, c])),
23+
[columns],
24+
);
25+
26+
const renderCellInput = (row: Row, colId: string) => {
27+
const col = columnById[colId];
28+
const value = row.values[colId];
29+
if (!col) return null;
30+
const common = {
31+
className:
32+
"w-full rounded border border-gray-300 dark:border-gray-600 px-2 py-1 text-sm bg-white dark:bg-gray-950 dark:text-gray-100",
33+
} as const;
34+
35+
const update = (v: any) =>
36+
onChangeRow(row.id, { ...row.values, [colId]: v });
37+
38+
switch (col.type) {
39+
case "text":
40+
return (
41+
<input
42+
{...common}
43+
value={(value as string) ?? ""}
44+
onChange={(e) => update(e.target.value)}
45+
/>
46+
);
47+
case "number":
48+
if (typeof value !== "number" && value !== null) {
49+
// keep input controlled
50+
}
51+
return (
52+
<input
53+
{...common}
54+
type="number"
55+
value={typeof value === "number" ? value : ""}
56+
onChange={(e) =>
57+
update(e.target.value === "" ? null : Number(e.target.value))
58+
}
59+
/>
60+
);
61+
case "boolean":
62+
return (
63+
<input
64+
type="checkbox"
65+
className="h-4 w-4"
66+
checked={Boolean(value)}
67+
onChange={(e) => update(e.target.checked)}
68+
/>
69+
);
70+
case "select":
71+
return (
72+
<select
73+
{...common}
74+
value={(value as string) ?? ""}
75+
onChange={(e) => update(e.target.value || null)}
76+
>
77+
<option value="">(none)</option>
78+
{(col.options ?? []).map((opt) => (
79+
<option key={opt.id} value={opt.id}>
80+
{opt.label}
81+
</option>
82+
))}
83+
</select>
84+
);
85+
case "multi-select":
86+
return (
87+
<MultiSelect
88+
options={col.options ?? []}
89+
value={Array.isArray(value) ? (value as string[]) : []}
90+
onChange={(val) => update(val)}
91+
/>
92+
);
93+
case "date":
94+
const dateString = (() => {
95+
if (value instanceof Date) return value.toISOString().slice(0, 10);
96+
if (typeof value === "string" || typeof value === "number") {
97+
const d = new Date(value);
98+
return Number.isNaN(d.getTime())
99+
? ""
100+
: d.toISOString().slice(0, 10);
101+
}
102+
return "";
103+
})();
104+
return (
105+
<input
106+
{...common}
107+
type="date"
108+
value={dateString}
109+
onChange={(e) =>
110+
update(e.target.value ? new Date(e.target.value) : null)
111+
}
112+
/>
113+
);
114+
default:
115+
return null;
116+
}
117+
};
118+
119+
return (
120+
<div className="space-y-2">
121+
<div className="flex justify-between items-center">
122+
<h3 className="text-sm font-semibold">Rows</h3>
123+
<button
124+
className="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-500"
125+
onClick={addRow}
126+
>
127+
Add row
128+
</button>
129+
</div>
130+
<div className="overflow-auto overflow-x-auto rounded-md border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900">
131+
<table className="min-w-full divide-y divide-gray-200 text-left text-sm dark:divide-gray-700">
132+
<thead className="bg-gray-100 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:bg-gray-800 dark:text-gray-400">
133+
<tr>
134+
<th className="px-4 py-2">#</th>
135+
{columns.map((col) => (
136+
<th
137+
key={col.id}
138+
className={`px-4 py-2 ${
139+
col.type === "boolean" ? "min-w-20" : "min-w-[150px]"
140+
}`}
141+
>
142+
{col.name}
143+
</th>
144+
))}
145+
<th className="px-4 py-2" />
146+
</tr>
147+
</thead>
148+
<tbody className="divide-y divide-gray-200 dark:divide-gray-800">
149+
{rows.map((row, idx) => (
150+
<tr
151+
key={row.id}
152+
className="odd:bg-white even:bg-gray-50 dark:odd:bg-gray-900 dark:even:bg-gray-800"
153+
>
154+
<td className="px-4 py-2 text-xs text-gray-500 dark:text-gray-400">
155+
{idx + 1}
156+
</td>
157+
{columns.map((col) => (
158+
<td key={col.id} className="px-4 py-2 align-top">
159+
{renderCellInput(row, col.id)}
160+
</td>
161+
))}
162+
<td className="px-4 py-2 text-right">
163+
<button
164+
className="text-xs text-red-600 dark:text-red-400 hover:underline"
165+
onClick={() => onRemoveRow(row.id)}
166+
>
167+
Remove
168+
</button>
169+
</td>
170+
</tr>
171+
))}
172+
</tbody>
173+
</table>
174+
</div>
175+
</div>
176+
);
177+
};

0 commit comments

Comments
 (0)