Skip to content

Commit d02ac20

Browse files
feat: Dune Widgets (#688)
Co-authored-by: elcharitas <jonathanirhodia@gmail.com>
1 parent ec336b7 commit d02ac20

23 files changed

Lines changed: 893 additions & 30 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Logger } from "src/api/utils/logging";
2+
import CONFIG from "../../../config/config";
3+
import { alphadayApi } from "../alphadayApi";
4+
import type {
5+
TImportDuneRequest,
6+
TImportDuneResponse,
7+
TGetDatasetByIdRequest,
8+
TGetDatasetByIdResponse,
9+
} from "./types";
10+
11+
const { DATASETS } = CONFIG.API.DEFAULT.ROUTES;
12+
13+
export const duneApi = alphadayApi.injectEndpoints({
14+
endpoints: (builder) => ({
15+
importDune: builder.mutation<TImportDuneResponse, TImportDuneRequest>({
16+
query: (req) => {
17+
const path = `${DATASETS.BASE}${DATASETS.IMPORT_DUNE}`;
18+
Logger.debug("importDune: body", JSON.stringify(req));
19+
return {
20+
url: path,
21+
body: req,
22+
method: "POST",
23+
};
24+
},
25+
}),
26+
getDatasetById: builder.query<
27+
TGetDatasetByIdResponse,
28+
TGetDatasetByIdRequest
29+
>({
30+
query: (req) => {
31+
const path = `${DATASETS.BASE}${DATASETS.BY_ID(req.id)}`;
32+
Logger.debug("getDatasetById: querying", path);
33+
return path;
34+
},
35+
}),
36+
}),
37+
overrideExisting: false,
38+
});
39+
40+
export const { useImportDuneMutation, useGetDatasetByIdQuery } = duneApi;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { TCustomItem } from "src/api/types";
2+
import { TPagination } from "../baseTypes";
3+
import { TRemoteCustomMeta } from "../views/types";
4+
5+
export type TImportDuneRequest = {
6+
query_id: string;
7+
cached?: boolean;
8+
};
9+
10+
export type TImportDuneResponse = {
11+
id: number;
12+
data: TCustomItem[];
13+
meta: TRemoteCustomMeta;
14+
};
15+
16+
export type TGetDatasetByIdRequest = {
17+
id: number;
18+
};
19+
20+
export type TGetDatasetByIdResponse = TPagination & {
21+
results: TCustomItem[];
22+
};

packages/frontend/src/api/services/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ export * from "./superfeed/types";
8484
export * from "./polymarket/polymarketEndpoints";
8585
export * from "./polymarket/types";
8686

87+
export * from "./dune/duneEndpoints";
88+
export * from "./dune/types";
89+
8790
/**
8891
* alphadayApi export should be last
8992
*/

packages/frontend/src/api/services/views/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,3 +443,12 @@ export type TWidgetsCategoryRequest = {
443443
export type TWidgetsCategoryResponse = TPagination & {
444444
results: ReadonlyArray<TRemoteWidgetCategory>;
445445
};
446+
447+
export type TUpdateWidgetSettingsRequest = {
448+
widget_hash: string;
449+
setting_slug: string;
450+
selected_dataset: number;
451+
};
452+
export type TUpdateWidgetSettingsResponse = {
453+
success: boolean;
454+
};

packages/frontend/src/api/services/views/viewsEndpoints.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ import type {
4242
TViewByIdRawResponse,
4343
TViewByHashOrSlugRawResponse,
4444
TViewForWalletRawResponse,
45+
TUpdateWidgetSettingsRequest,
46+
TUpdateWidgetSettingsResponse,
4547
} from "./types";
4648

4749
const { VIEWS } = CONFIG.API.DEFAULT.ROUTES;
@@ -282,6 +284,22 @@ const viewsApi = alphadayApi.injectEndpoints({
282284
return path;
283285
},
284286
}),
287+
updateWidgetSettings: builder.mutation<
288+
TUpdateWidgetSettingsResponse,
289+
TUpdateWidgetSettingsRequest
290+
>({
291+
query: (req) => ({
292+
url: `${VIEWS.BASE}${VIEWS.WIDGET_SETTINGS_UPDATE_BY_HASH(
293+
req.widget_hash
294+
)}`,
295+
method: "POST",
296+
body: {
297+
setting_slug: req.setting_slug,
298+
selected_dataset: req.selected_dataset,
299+
},
300+
}),
301+
invalidatesTags: ["Views", "CurrentView"],
302+
}),
285303
}),
286304
overrideExisting: false,
287305
});
@@ -301,4 +319,5 @@ export const {
301319
useGetViewByHashQuery,
302320
useGetWidgetsCategoryQuery,
303321
useGetViewForWalletQuery,
322+
useUpdateWidgetSettingsMutation,
304323
} = viewsApi;

packages/frontend/src/api/store/providers/dimensions-context.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ export const DimensionsProvider: FC<{ children?: React.ReactNode }> = ({
7676
const elem = document.getElementById(WIDGET_SIZE_TRACKING_ID);
7777
if (elem) {
7878
previousElement.current = elem;
79+
setWidgetSize({
80+
width: elem.clientWidth,
81+
height: elem.clientHeight,
82+
});
7983
resizeObserver.observe(elem);
8084
mutationObserver.disconnect();
8185
}
@@ -119,6 +123,10 @@ export const DimensionsProvider: FC<{ children?: React.ReactNode }> = ({
119123
const elem = document.getElementById(IMAGE_WIDGET_SIZE_TRACKING_ID);
120124
if (elem) {
121125
previousImageElement.current = elem;
126+
setWidgetSize({
127+
width: elem.clientWidth,
128+
height: elem.clientHeight,
129+
});
122130
imageResizeObserver.observe(elem);
123131
imageMutationObserver.disconnect();
124132
}

packages/frontend/src/api/store/slices/views.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import assert from "assert";
22
import { createSlice, createSelector, PayloadAction } from "@reduxjs/toolkit";
3-
import { TRemoteUserView } from "src/api/services";
3+
import {
4+
TRemoteUserView,
5+
TRemoteCustomData,
6+
TRemoteCustomMeta,
7+
} from "src/api/services";
48
import { logout } from "src/api/services/user/userEndpoints";
59
import { RootState } from "src/api/store/store";
610
import {
@@ -481,6 +485,56 @@ const viewsSlice = createSlice({
481485
"slices::views::includeTagInViewWidget: widget tags have been updated"
482486
);
483487
},
488+
updateWidgetCustomDataMeta(
489+
draft,
490+
action: PayloadAction<{
491+
widgetHash: string;
492+
custom_data?: TRemoteCustomData | undefined;
493+
custom_meta?: TRemoteCustomMeta | undefined;
494+
}>
495+
) {
496+
/* eslint-disable @typescript-eslint/naming-convention */
497+
const { widgetHash, custom_data, custom_meta } = action.payload;
498+
/* eslint-enable @typescript-eslint/naming-convention */
499+
const selectedView = getSelectedViewRefFromDraft(draft);
500+
if (selectedView === undefined) {
501+
Logger.error(
502+
"slices::views::updateWidgetCustomDataMeta: selectedView is undefined, should never happen"
503+
);
504+
return;
505+
}
506+
const widget = selectedView.data.widgets.find(
507+
(w) => w.hash === widgetHash
508+
);
509+
if (widget === undefined) {
510+
Logger.error(
511+
"slices::views::updateWidgetCustomDataMeta: could not find widget. Should never happen"
512+
);
513+
return;
514+
}
515+
if (custom_data !== undefined) {
516+
widget.widget.custom_data = custom_data;
517+
}
518+
if (custom_meta !== undefined) {
519+
widget.widget.custom_meta = custom_meta;
520+
}
521+
selectedView.lastModified = new Date().toISOString();
522+
if (
523+
draft.subscribedViewsCache !== undefined &&
524+
draft.subscribedViewsCache[selectedView.data.id] !== undefined
525+
) {
526+
draft.subscribedViewsCache[selectedView.data.id].lastModified =
527+
selectedView.lastModified;
528+
} else if (!selectedView.isReadOnly) {
529+
Logger.warn(
530+
"slices::views::updateWidgetCustomDataMeta: could not find subscribed view in cache"
531+
);
532+
}
533+
Logger.debug(
534+
"slices::views::updateWidgetCustomDataMeta: widget custom_data/custom_meta updated",
535+
{ widgetHash }
536+
);
537+
},
484538
removeTagFromAllWidgets(
485539
draft,
486540
action: PayloadAction<{ tagId: number }>
@@ -703,6 +757,7 @@ export const {
703757
removeTagFromViewWidget,
704758
removeTagFromAllWidgets,
705759
includeTagInViewWidget,
760+
updateWidgetCustomDataMeta,
706761
addWidgetsToView,
707762
removeWidgetFromView,
708763
updateSubscribedViewsCache,

packages/frontend/src/api/utils/customDataUtils.tsx

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
TRemoteCustomMeta,
99
TCustomMetaChart,
1010
TCustomMetaCard,
11+
TRemoteCustomLayoutEntry,
1112
} from "src/api/services";
1213
import { TCustomItem, TCustomSeries } from "src/api/types";
1314
import { getErrorMessage } from "src/api/utils/errorHandling";
@@ -264,8 +265,26 @@ export const formatCustomDataField: (
264265
};
265266
}
266267
if (format === "date") {
268+
const parsedDate = moment(rawField, [
269+
moment.ISO_8601,
270+
"YYYY-MM-DD HH:mm:ss.SSS UTC",
271+
"YYYY-MM-DD HH:mm:ss UTC",
272+
"YYYY-MM-DD HH:mm:ss.SSS",
273+
"YYYY-MM-DD HH:mm:ss",
274+
]);
275+
276+
if (!parsedDate.isValid()) {
277+
Logger.warn(
278+
`formatCustomDataField: Invalid date format for "${rawField}"`
279+
);
280+
return {
281+
field: rawField,
282+
error: "Invalid date format",
283+
};
284+
}
285+
267286
return {
268-
field: moment(rawField).format("YYYY-MM-DDTHH:mmZ").toString(),
287+
field: parsedDate.format("YYYY-MM-DDTHH:mmZ").toString(),
269288
error: undefined,
270289
};
271290
}
@@ -452,8 +471,40 @@ export const getYSeries: (
452471
};
453472

454473
/**
455-
* Attemtps to extract the Card fields `title` and `value` from a custom_data and custom_meta objects.
474+
* Infers column layout from the first row of data when no columns are explicitly defined.
475+
* Generates sensible defaults for format, width, and template based on data types.
456476
*/
477+
export const generateColumnsFromRowData: (
478+
items: TRemoteCustomData
479+
) => TRemoteCustomLayoutEntry[] = (items) => {
480+
if (items.length === 0) {
481+
return [];
482+
}
483+
484+
const firstRow = items[0];
485+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
486+
const { id, ...dataFields } = firstRow;
487+
488+
return Object.entries(dataFields).map(([key, value], index) => {
489+
let format: TRemoteFormat = "plain-text";
490+
491+
// Infer format from value type
492+
if (typeof value === "number") {
493+
format = Number.isInteger(value) ? "number" : "decimal";
494+
} else if (typeof value === "boolean") {
495+
format = "checkmark";
496+
}
497+
498+
return {
499+
id: index,
500+
title: key,
501+
template: key,
502+
format,
503+
width: 1,
504+
};
505+
});
506+
};
507+
457508
export const customDataAsCardData: (
458509
customData: TRemoteCustomData,
459510
customMeta: TCustomMetaCard | undefined,
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Extracts the query ID from a Dune endpoint URL
3+
* Supports various Dune URL formats:
4+
* - https://dune.com/queries/[query_id]
5+
* - https://dune.com/queries/[query_id]/...
6+
* - https://dune.xyz/queries/[query_id]
7+
*
8+
* @param url - The Dune endpoint URL
9+
* @returns The extracted query ID or null if not found
10+
*/
11+
export const extractDuneQueryId = (url: string): string | null => {
12+
try {
13+
const urlObj = new URL(url);
14+
const pathParts = urlObj.pathname.split("/").filter(Boolean);
15+
16+
// Look for "queries" in the path and get the next part as the query ID
17+
const queriesIndex = pathParts.findIndex((part) => part === "queries");
18+
if (queriesIndex !== -1 && pathParts[queriesIndex + 1]) {
19+
return pathParts[queriesIndex + 1];
20+
}
21+
22+
return null;
23+
} catch (error) {
24+
return null;
25+
}
26+
};

0 commit comments

Comments
 (0)