Skip to content

Commit 47a0fb3

Browse files
authored
Merge pull request #398 from outerbase/custom-format-cell
feat: custom format cell
1 parent a55d518 commit 47a0fb3

File tree

10 files changed

+194
-7
lines changed

10 files changed

+194
-7
lines changed

src/components/gui/query-result-table.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export default function ResultTable({
136136

137137
const renderHeader = useCallback(
138138
(header: OptimizeTableHeaderWithIndexProps) => {
139-
const extensionMenu = extensions.getQueryHeaderContextMenu(header);
139+
const extensionMenu = extensions.getQueryHeaderContextMenu(header, data);
140140
const extensionMenuItems = extensionMenu.map((item) => {
141141
if (item.component) {
142142
return (

src/components/gui/table-cell/generic-cell.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,11 @@ export default function GenericCell({
203203
}, [header, value]);
204204

205205
const content = useMemo(() => {
206+
if (header.decorator) {
207+
const decoratorContent = header.decorator(value);
208+
if (decoratorContent !== null) return decoratorContent;
209+
}
210+
206211
if (value === null) {
207212
return <span className={textBaseStyle}>NULL</span>;
208213
}

src/components/gui/table-optimized/OptimizeTableState.tsx

+24-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
import { formatNumber } from "@/lib/convertNumber";
66
import { selectArrayFromIndexList } from "@/lib/export-helper";
77
import deepEqual from "deep-equal";
8-
import { OptimizeTableHeaderProps } from ".";
8+
import { OptimizeTableHeaderProps, TableCellDecorator } from ".";
99

1010
export interface OptimizeTableRowValue {
1111
raw: Record<string, unknown>;
@@ -42,6 +42,7 @@ export default class OptimizeTableState {
4242
public gutterColumnWidth = 40;
4343

4444
protected headers: OptimizeTableHeaderProps[] = [];
45+
public headerRevision = 1;
4546
protected headerWidth: number[] = [];
4647

4748
protected editMode = false;
@@ -203,6 +204,28 @@ export default class OptimizeTableState {
203204
return this.headers;
204205
}
205206

207+
updateHeaderDecorator(
208+
header: OptimizeTableHeaderProps,
209+
decorator: TableCellDecorator | undefined
210+
) {
211+
const idx = this.headers.findIndex((h) => h.name === header.name);
212+
213+
if (idx >= 0) {
214+
this.headers = this.headers.map((h) => {
215+
if (h.name === header.name) {
216+
return {
217+
...h,
218+
decorator,
219+
};
220+
}
221+
return h;
222+
});
223+
224+
this.headerRevision++;
225+
this.broadcastChange();
226+
}
227+
}
228+
206229
getValue(y: number, x: number): unknown {
207230
const rowChange = this.data[y]?.change;
208231
if (rowChange) {

src/components/gui/table-optimized/index.tsx

+9-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import TableFakeRowPadding from "./table-fake-row-padding";
1919
import TableHeaderList from "./table-header-list";
2020
import useTableVisibilityRecalculation from "./useTableVisibilityRecalculation";
2121

22+
export type TableCellDecorator = (value: unknown) => ReactElement | null;
23+
2224
export interface TableHeaderMetadata {
2325
from?: {
2426
schema: string;
@@ -41,6 +43,7 @@ export interface TableHeaderMetadata {
4143

4244
columnSchema?: DatabaseTableColumn;
4345
}
46+
4447
export interface OptimizeTableHeaderProps {
4548
name: string;
4649

@@ -52,6 +55,8 @@ export interface OptimizeTableHeaderProps {
5255
tooltip?: string;
5356
};
5457

58+
decorator?: TableCellDecorator;
59+
5560
setting: {
5661
resizable: boolean;
5762
readonly: boolean;
@@ -60,6 +65,7 @@ export interface OptimizeTableHeaderProps {
6065
onContextMenu?: (e: React.MouseEvent, headerIndex: number) => void;
6166

6267
metadata: TableHeaderMetadata;
68+
store: Map<string, unknown>;
6369
}
6470

6571
export interface OptimizeTableHeaderWithIndexProps
@@ -279,12 +285,14 @@ export default function OptimizeTable({
279285
return () => internalState.removeChangeListener(changeCallback);
280286
}, [internalState, rerender]);
281287

288+
const headerRevision = internalState.headerRevision;
282289
const headerWithIndex = useMemo(() => {
283290
// Attach the actual index
284291
const headers = internalState.getHeaders().map((header, idx) => ({
285292
...header,
286293
index: idx,
287294
sticky: idx === stickyHeaderIndex,
295+
headerRevision, // this is basically useless, but we do it to ignore deps warning
288296
}));
289297

290298
// We will rearrange the index based on specified index
@@ -297,7 +305,7 @@ export default function OptimizeTable({
297305
...(stickyHeaderIndex !== undefined ? [headers[stickyHeaderIndex]] : []),
298306
...headerAfterArranged.filter((x) => x.index !== stickyHeaderIndex),
299307
];
300-
}, [internalState, arrangeHeaderIndex, stickyHeaderIndex]);
308+
}, [internalState, arrangeHeaderIndex, stickyHeaderIndex, headerRevision]);
301309

302310
const { visibileRange, onHeaderResize } = useTableVisibilityRecalculation({
303311
containerRef,

src/components/ui/dropdown-menu.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
3131
<DropdownMenuPrimitive.SubTrigger
3232
ref={ref}
3333
className={cn(
34-
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
34+
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-sm px-2 py-1.5 text-base outline-hidden select-none",
3535
inset && "pl-8",
3636
className
3737
)}

src/core/extension-manager.tsx

+8-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { OptimizeTableHeaderProps } from "@/components/gui/table-optimized";
2+
import OptimizeTableState from "@/components/gui/table-optimized/OptimizeTableState";
23
import { DatabaseSchemaItem, DatabaseSchemas } from "@/drivers/base-driver";
34
import { ReactElement } from "react";
45
import { IStudioExtension } from "./extension-base";
@@ -28,7 +29,8 @@ type CreateResourceMenuHandler = (
2829
) => StudioExtensionMenuItem | undefined;
2930

3031
type QueryHeaderResultMenuHandler = (
31-
header: OptimizeTableHeaderProps
32+
header: OptimizeTableHeaderProps,
33+
state: OptimizeTableState
3234
) => StudioExtensionMenuItem | undefined;
3335

3436
type AfterFetchSchemaHandler = (schema: DatabaseSchemas) => void;
@@ -120,9 +122,12 @@ export class StudioExtensionManager extends StudioExtensionContext {
120122
.filter(Boolean) as StudioExtensionMenuItem[];
121123
}
122124

123-
getQueryHeaderContextMenu(header: OptimizeTableHeaderProps) {
125+
getQueryHeaderContextMenu(
126+
header: OptimizeTableHeaderProps,
127+
state: OptimizeTableState
128+
) {
124129
return this.queryResultHeaderContextMenu
125-
.map((handler) => handler(header))
130+
.map((handler) => handler(header, state))
126131
.filter(Boolean) as StudioExtensionMenuItem[];
127132
}
128133

src/core/standard-extension.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import ColumnDescriptorExtension from "@/extensions/column-descriptor";
6+
import DataDecoratorExtension from "@/extensions/data-decorator";
67
import QueryHistoryConsoleLogExtension from "@/extensions/query-console-log";
78
import TriggerEditorExtension from "@/extensions/trigger-editor";
89
import ViewEditorExtension from "@/extensions/view-editor";
@@ -12,6 +13,7 @@ export function createStandardExtensions() {
1213
new QueryHistoryConsoleLogExtension(),
1314
new ViewEditorExtension(),
1415
new ColumnDescriptorExtension(),
16+
new DataDecoratorExtension(),
1517
];
1618
}
1719

+126
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { OptimizeTableHeaderProps } from "@/components/gui/table-optimized";
2+
import OptimizeTableState from "@/components/gui/table-optimized/OptimizeTableState";
3+
import {
4+
DropdownMenuItem,
5+
DropdownMenuSeparator,
6+
DropdownMenuSub,
7+
DropdownMenuSubContent,
8+
DropdownMenuSubTrigger,
9+
} from "@/components/ui/dropdown-menu";
10+
import { Check } from "@phosphor-icons/react";
11+
import { useState } from "react";
12+
import z from "zod";
13+
14+
function currencyDecorator(value: unknown) {
15+
if (typeof value === "number" || typeof value === "bigint") {
16+
return (
17+
<div className="w-full text-right">
18+
{new Intl.NumberFormat("en-US", {
19+
style: "currency",
20+
currency: "USD",
21+
}).format(Number(value))}
22+
</div>
23+
);
24+
}
25+
26+
return null;
27+
}
28+
29+
function unixToDateTimeDecorator(value: unknown) {
30+
if (typeof value === "number" || typeof value === "bigint") {
31+
return (
32+
<div className="w-full text-right">
33+
{new Date(Number(value) * 1000).toISOString()}
34+
</div>
35+
);
36+
}
37+
38+
return null;
39+
}
40+
41+
function unixMsToDateTimeDecorator(value: unknown) {
42+
if (typeof value === "number" || typeof value === "bigint") {
43+
return (
44+
<div className="w-full text-right">
45+
{new Date(Number(value)).toISOString()}
46+
</div>
47+
);
48+
}
49+
50+
return null;
51+
}
52+
53+
export function DecoratorEditor({
54+
header,
55+
state,
56+
}: {
57+
header: OptimizeTableHeaderProps;
58+
state: OptimizeTableState;
59+
}) {
60+
const [type, setType] = useState(() => {
61+
const setting = header.store.get("decorator");
62+
63+
const schema = z.object({ type: z.enum(["currency", "unix", "unix-ms"]) });
64+
const validated = schema.safeParse(setting);
65+
66+
if (validated.error) {
67+
return null;
68+
}
69+
70+
return validated.data.type;
71+
});
72+
73+
return (
74+
<DropdownMenuSub>
75+
<DropdownMenuSubTrigger inset>Format</DropdownMenuSubTrigger>
76+
<DropdownMenuSubContent>
77+
<DropdownMenuItem
78+
inset={type !== null}
79+
onClick={() => {
80+
state.updateHeaderDecorator(header, undefined);
81+
setType(null);
82+
header.store.set("decorator", undefined);
83+
}}
84+
>
85+
{type === null ? <Check className="mr-2 h-4 w-4" /> : null}
86+
None
87+
</DropdownMenuItem>
88+
<DropdownMenuSeparator />
89+
<DropdownMenuItem
90+
inset={type !== "currency"}
91+
onClick={() => {
92+
state.updateHeaderDecorator(header, currencyDecorator);
93+
setType("currency");
94+
header.store.set("decorator", { type: "currency" });
95+
}}
96+
>
97+
{type === "currency" ? <Check className="mr-2 h-4 w-4" /> : null}
98+
Currency
99+
</DropdownMenuItem>
100+
<DropdownMenuSeparator />
101+
<DropdownMenuItem
102+
inset={type !== "unix-ms"}
103+
onClick={() => {
104+
state.updateHeaderDecorator(header, unixMsToDateTimeDecorator);
105+
setType("unix-ms");
106+
header.store.set("decorator", { type: "unix-ms" });
107+
}}
108+
>
109+
{type === "unix-ms" ? <Check className="mr-2 h-4 w-4" /> : null}
110+
Unix Timestamp (ms) to datetime
111+
</DropdownMenuItem>
112+
<DropdownMenuItem
113+
inset={type !== "unix"}
114+
onClick={() => {
115+
state.updateHeaderDecorator(header, unixToDateTimeDecorator);
116+
setType("unix");
117+
header.store.set("decorator", { type: "unix" });
118+
}}
119+
>
120+
{type === "unix" ? <Check className="mr-2 h-4 w-4" /> : null}
121+
Unix Timestamp (s) to datetime
122+
</DropdownMenuItem>
123+
</DropdownMenuSubContent>
124+
</DropdownMenuSub>
125+
);
126+
}
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { StudioExtension } from "@/core/extension-base";
2+
import { StudioExtensionContext } from "@/core/extension-manager";
3+
import { DecoratorEditor } from "./editor";
4+
5+
export default class DataDecoratorExtension extends StudioExtension {
6+
extensionName = "data-decorator";
7+
8+
init(studio: StudioExtensionContext): void {
9+
studio.registerQueryHeaderContextMenu((header, state) => {
10+
return {
11+
key: "data-decorator",
12+
title: "Decorate",
13+
component: <DecoratorEditor header={header} state={state} />,
14+
};
15+
});
16+
}
17+
}

src/lib/build-table-result.ts

+1
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ export function buildTableResultHeader(
269269
}
270270

271271
return {
272+
store: new Map(),
272273
name: column.name,
273274
display: {
274275
text: column.displayName,

0 commit comments

Comments
 (0)