This commit is contained in:
Morten Olsen
2023-06-16 11:10:50 +02:00
commit bc0d501d98
163 changed files with 16458 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
import {
createContext,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { v4 as uuid } from 'uuid';
import { Boards, BoardsLoad, BoardsSave } from './types';
type BoardsContextValue = {
selected?: string;
boards: Boards;
addBoard: (name: string) => void;
selectBoard: (id: string) => void;
removeBoard: (id: string) => void;
addWidget: (boardId: string, type: string, data: string) => void;
removeWidget: (boardId: string, widgetId: string) => void;
updateWidget: (boardId: string, widgetId: string, data: string) => void;
};
type BoardsProviderProps = {
children: React.ReactNode;
load: BoardsLoad;
save: BoardsSave;
};
const BoardsContext = createContext<BoardsContextValue | null>(null);
const BoardsProvider: React.FC<BoardsProviderProps> = ({
children,
load,
save,
}) => {
const [boards, setBoards] = useState<Boards>(load().boards);
const [selected, setSelected] = useState<string | undefined>(load().selected);
const addBoard = useCallback((name: string) => {
const id = uuid();
setBoards((currentBoards) => ({
...currentBoards,
[id]: {
name,
widgets: {},
},
}));
}, []);
const removeBoard = useCallback((id: string) => {
setBoards((currentBoards) => {
const copy = { ...currentBoards };
delete copy[id];
return copy;
});
}, []);
const addWidget = useCallback(
(boardId: string, type: string, data: string) => {
const id = uuid();
setBoards((currentBoards) => ({
...currentBoards,
[boardId]: {
...currentBoards[boardId],
widgets: {
...currentBoards[boardId].widgets,
[id]: {
type,
data,
},
},
},
}));
},
[],
);
const removeWidget = useCallback((boardId: string, widgetId: string) => {
setBoards((currentBoards) => {
const copy = { ...currentBoards };
delete copy[boardId].widgets[widgetId];
return copy;
});
}, []);
const updateWidget = useCallback(
(boardId: string, widgetId: string, data: string) => {
setBoards((currentBoards) => ({
...currentBoards,
[boardId]: {
...currentBoards[boardId],
widgets: {
...currentBoards[boardId].widgets,
[widgetId]: {
...currentBoards[boardId].widgets[widgetId],
data,
},
},
},
}));
},
[],
);
const selectBoard = useCallback((id: string) => {
setSelected(id);
}, []);
useEffect(() => {
save({ boards, selected });
}, [boards, selected, save]);
const value = useMemo(
() => ({
boards,
selected,
addBoard,
removeBoard,
addWidget,
removeWidget,
selectBoard,
updateWidget,
}),
[
boards,
selected,
addBoard,
removeBoard,
addWidget,
removeWidget,
selectBoard,
updateWidget,
],
);
return (
<BoardsContext.Provider value={value}>{children}</BoardsContext.Provider>
);
};
export { BoardsContext, BoardsProvider };

View File

@@ -0,0 +1,77 @@
import { useContext } from 'react';
import { BoardsContext } from './context';
const useBoards = () => {
const context = useContext(BoardsContext);
if (!context) {
throw new Error('useBoards must be used within a BoardsProvider');
}
return context.boards;
};
const useSelectedBoard = () => {
const context = useContext(BoardsContext);
if (!context) {
throw new Error('useCurrentBoard must be used within a BoardsProvider');
}
return context.selected;
};
const useAddWidget = () => {
const context = useContext(BoardsContext);
if (!context) {
throw new Error('useAddWidget must be used within a BoardsProvider');
}
return context.addWidget;
};
const useRemoveWidget = () => {
const context = useContext(BoardsContext);
if (!context) {
throw new Error('useRemoveWidget must be used within a BoardsProvider');
}
return context.removeWidget;
};
const useAddBoard = () => {
const context = useContext(BoardsContext);
if (!context) {
throw new Error('useAddBoard must be used within a BoardsProvider');
}
return context.addBoard;
};
const useRemoveBoard = () => {
const context = useContext(BoardsContext);
if (!context) {
throw new Error('useRemoveBoard must be used within a BoardsProvider');
}
return context.removeBoard;
};
const useSelectBoard = () => {
const context = useContext(BoardsContext);
if (!context) {
throw new Error('useSelectBoard must be used within a BoardsProvider');
}
return context.selectBoard;
};
const useUpdateWidget = () => {
const context = useContext(BoardsContext);
if (!context) {
throw new Error('useUpdateWidget must be used within a BoardsProvider');
}
return context.updateWidget;
};
export {
useBoards,
useSelectedBoard,
useAddWidget,
useRemoveWidget,
useAddBoard,
useRemoveBoard,
useSelectBoard,
useUpdateWidget,
};

View File

@@ -0,0 +1,12 @@
export { BoardsProvider } from './context';
export {
useBoards,
useSelectedBoard,
useAddWidget,
useRemoveWidget,
useAddBoard,
useRemoveBoard,
useSelectBoard,
useUpdateWidget,
} from './hooks';
export * from './types';

View File

@@ -0,0 +1,23 @@
type BoardData = { boards: Boards; selected?: string };
type BoardsLoad = () => BoardData;
type BoardsSave = (data: BoardData) => void;
type BoardWidget = {
type: string;
data: any;
};
type Board = {
name: string;
widgets: {
[key: string]: BoardWidget;
};
};
type Boards = {
[key: string]: Board;
};
export type { Board, Boards, BoardWidget, BoardsLoad, BoardsSave };

View File

@@ -0,0 +1,71 @@
import { createContext, useCallback, useMemo, useState } from 'react';
import { Octokit } from 'octokit';
type GithubLogin = React.ComponentType<{
setToken: (token: string) => void;
cancel: () => void;
}>;
type GithubClientContextValue = {
octokit?: Octokit;
login?: () => void;
logout: () => void;
};
type GithubClientProviderProps = {
children: React.ReactNode;
login: GithubLogin;
};
const GithubClientContext = createContext<GithubClientContextValue | null>(
null,
);
const GithubClientProvider: React.FC<GithubClientProviderProps> = ({
children,
login: GithubLoginComponent,
}) => {
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [pat, setPat] = useState(localStorage.getItem('github_pat') || '');
const octokit = useMemo(() => {
if (!pat) {
return;
}
localStorage.setItem('github_pat', pat);
return new Octokit({ auth: pat });
}, [pat]);
const logout = useCallback(() => {
setPat('');
localStorage.removeItem('github_pat');
}, [setPat]);
const login = useCallback(() => {
setIsLoggingIn(true);
}, [setIsLoggingIn]);
const onLogin = useCallback(
(nextPat: string) => {
setPat(nextPat);
setIsLoggingIn(false);
},
[setPat, setIsLoggingIn],
);
const cancelLogin = useCallback(() => {
setIsLoggingIn(false);
}, [setIsLoggingIn]);
return (
<GithubClientContext.Provider value={{ octokit, logout, login }}>
{isLoggingIn && (
<GithubLoginComponent cancel={cancelLogin} setToken={onLogin} />
)}
{children}
</GithubClientContext.Provider>
);
};
export type { GithubLogin };
export { GithubClientContext, GithubClientProvider };

View File

@@ -0,0 +1,64 @@
import { useCallback, useContext, useMemo, useState } from 'react';
import { GithubClientContext } from './context';
import { Octokit } from 'octokit';
const useGithub = () => {
const context = useContext(GithubClientContext);
if (!context) {
throw new Error(
'useGithubClient must be used within a GithubClientProvider',
);
}
const value = useMemo(
() => ({
client: context.octokit,
logout: context.logout,
login: context.login,
}),
[context.octokit, context.logout, context.login],
);
return value;
};
const useGithubQuery = <P, T = unknown>(
query: (client: Octokit, params: P) => Promise<T>,
) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<unknown | null>(null);
const [data, setData] = useState<T>();
const { client } = useGithub();
const fetch = useCallback(
async (params: P) => {
if (!client) {
throw new Error('Github client is not initialized');
}
try {
setLoading(true);
const data = await query(client, params);
setData(data);
return data;
} catch (error) {
setError(error);
} finally {
setLoading(false);
}
},
[client, query],
);
const value = useMemo(
() => ({
loading,
error,
data,
fetch,
}),
[loading, error, data, fetch],
);
return value;
};
export { useGithub, useGithubQuery };

View File

@@ -0,0 +1,4 @@
export { GithubClientProvider, GithubLogin } from './context';
export { useGithub, useGithubQuery } from './hooks';
export { withGithub } from './with-github';
export * as GithubTypes from './types';

View File

@@ -0,0 +1,12 @@
import { Octokit } from 'octokit';
import { AsyncResponse } from '../../utils/types';
type PullRequest = AsyncResponse<Octokit['rest']['pulls']['get']>['data'];
type Commit = AsyncResponse<Octokit['rest']['repos']['getCommit']>['data'];
type WorkflowRun = AsyncResponse<
Octokit['rest']['actions']['listWorkflowRuns']
>['data']['workflow_runs'][number];
type Profile = PullRequest['user'];
type Repository = PullRequest['base']['repo'];
export { PullRequest, Profile, Repository, WorkflowRun, Commit };

View File

@@ -0,0 +1,19 @@
import { useGithub } from '.';
const withGithub = <TProps extends object>(
Component: React.ComponentType<TProps>,
Fallback: React.ComponentType<object>,
) => {
const WrappedComponent: React.FC<TProps> = (props) => {
const github = useGithub();
if (!github.client) {
return <Fallback />;
}
return <Component {...props} />;
};
return WrappedComponent;
};
export { withGithub };

View File

@@ -0,0 +1,17 @@
export {
useGithub,
useGithubQuery,
withGithub,
GithubTypes,
GithubLogin,
} from './github';
export { useLinear, useLinearQuery, withLinear, LinearLogin } from './linear';
export {
useSlack,
useSlackQuery,
withSlack,
SlackClient,
SlackTypes,
SlackLogin,
} from './slack';
export { ClientProvider } from './provider';

View File

@@ -0,0 +1,73 @@
import { LinearClient } from '@linear/sdk';
import { createContext, useCallback, useMemo, useState } from 'react';
type LinearLogin = React.ComponentType<{
setApiKey: (apiKey: string) => void;
cancel: () => void;
}>;
type LinearClientContextValue = {
client?: LinearClient;
logout: () => void;
login?: () => void;
};
type LinearClientProviderProps = {
children: React.ReactNode;
login: LinearLogin;
};
const LinearClientContext = createContext<LinearClientContextValue | null>(
null,
);
const LinearClientProvider: React.FC<LinearClientProviderProps> = ({
children,
login: LinearLoginComponent,
}) => {
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [apiKey, setApiKey] = useState(
localStorage.getItem('linear_token') || '',
);
const client = useMemo(() => {
if (!apiKey) {
return;
}
localStorage.setItem('linear_token', apiKey);
return new LinearClient({ apiKey: apiKey });
}, [apiKey]);
const logout = useCallback(() => {
setApiKey('');
localStorage.removeItem('linear_token');
}, [setApiKey]);
const login = useCallback(() => {
setIsLoggingIn(true);
}, [setIsLoggingIn]);
const onLogin = useCallback(
(nextApiKey: string) => {
setApiKey(nextApiKey);
setIsLoggingIn(false);
},
[setApiKey, setIsLoggingIn],
);
const cancelLogin = useCallback(() => {
setIsLoggingIn(false);
}, [setIsLoggingIn]);
return (
<LinearClientContext.Provider value={{ client, login, logout }}>
{isLoggingIn && (
<LinearLoginComponent setApiKey={onLogin} cancel={cancelLogin} />
)}
{children}
</LinearClientContext.Provider>
);
};
export type { LinearLogin };
export { LinearClientContext, LinearClientProvider };

View File

@@ -0,0 +1,61 @@
import { useCallback, useContext, useMemo, useState } from 'react';
import { LinearClientContext } from './context';
import { LinearClient } from '@linear/sdk';
const useLinear = () => {
const context = useContext(LinearClientContext);
if (!context) {
throw new Error(
'useLinearClient must be used within a LinearClientProvider',
);
}
const value = useMemo(
() => ({
client: context.client,
logout: context.logout,
login: context.login,
}),
[context.client, context.logout, context.login],
);
return value;
};
const useLinearQuery = <T = unknown>(
query: (client: LinearClient) => Promise<T>,
) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<unknown | null>(null);
const [data, setData] = useState<T | null>(null);
const { client } = useLinear();
const fetch = useCallback(async () => {
if (!client) {
throw new Error('Linear client is not initialized');
}
try {
setLoading(true);
const data = await query(client);
setData(data);
return data;
} catch (error) {
setError(error);
} finally {
setLoading(false);
}
}, [client, query]);
const value = useMemo(
() => ({
loading,
error,
data,
fetch,
}),
[loading, error, data, fetch],
);
return value;
};
export { useLinear, useLinearQuery };

View File

@@ -0,0 +1,3 @@
export { LinearClientProvider, LinearLogin } from './context';
export { useLinear, useLinearQuery } from './hooks';
export { withLinear } from './with-linear';

View File

@@ -0,0 +1,19 @@
import { useLinear } from '.';
const withLinear = <TProps extends object>(
Component: React.ComponentType<TProps>,
FallBack: React.ComponentType<object>,
) => {
const WrappedComponent: React.FC<TProps> = (props) => {
const linear = useLinear();
if (!linear.client) {
return <FallBack />;
}
return <Component {...props} />;
};
return WrappedComponent;
};
export { withLinear };

View File

@@ -0,0 +1,29 @@
import { GithubClientProvider, GithubLogin } from './github';
import { LinearClientProvider, LinearLogin } from './linear';
import { SlackClientProvider, SlackLogin } from './slack';
type ClientProviderProps = {
children: React.ReactNode;
logins: {
github: GithubLogin;
linear: LinearLogin;
slack: SlackLogin;
};
};
const ClientProvider: React.FC<ClientProviderProps> = ({
children,
logins,
}) => {
return (
<LinearClientProvider login={logins.linear}>
<GithubClientProvider login={logins.github}>
<SlackClientProvider login={logins.slack}>
{children}
</SlackClientProvider>
</GithubClientProvider>
</LinearClientProvider>
);
};
export { ClientProvider };

View File

@@ -0,0 +1,59 @@
import type { WebClient } from '@slack/web-api';
import type { Expand } from '../../utils/types';
type MethodFromString<T extends `${string}.${string}`> =
T extends `${infer A}.${infer B}`
? A extends keyof WebClient
? B extends keyof WebClient[A]
? WebClient[A][B] extends (...args: any) => any
? (
...args: Parameters<WebClient[A][B]>
) => ReturnType<WebClient[A][B]>
: never
: never
: never
: never;
type ParamsFromString<T extends `${string}.${string}`> =
MethodFromString<T> extends (arg: infer P) => unknown
? P extends Record<string, any>
? P
: never
: never;
type ReturnFromString<T extends `${string}.${string}`> =
MethodFromString<T> extends () => infer R
? R extends Promise<infer P>
? P
: never
: never;
class SlackClient {
#token: string;
constructor(token: string) {
this.#token = token;
}
public send = async <K extends `${string}.${string}`>(
action: K,
params: Expand<ParamsFromString<K>>,
): Promise<Expand<ReturnFromString<K>>> => {
const form = new FormData();
form.append('token', this.#token);
Object.keys(params).forEach((key) => {
form.append(key, params[key as keyof typeof params]);
});
const response = await fetch(`https://slack.com/api/${action}`, {
mode: 'cors',
method: 'POST',
body: form,
});
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.statusText} ${action}`);
}
return response.json();
};
}
export type { ReturnFromString };
export { SlackClient };

View File

@@ -0,0 +1,69 @@
import { createContext, useCallback, useMemo, useState } from 'react';
import { SlackClient } from './client';
type SlackLogin = React.ComponentType<{
setToken: (token: string) => void;
cancel: () => void;
}>;
type SlackClientContextValue = {
client?: SlackClient;
logout: () => void;
login: () => void;
};
type SlackClientProviderProps = {
children: React.ReactNode;
login: SlackLogin;
};
const SlackClientContext = createContext<SlackClientContextValue | null>(null);
const SlackClientProvider: React.FC<SlackClientProviderProps> = ({
children,
login: SlackLoginComponent,
}) => {
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [token, setToken] = useState(localStorage.getItem('slack_token') || '');
const client = useMemo(() => {
if (!token) {
return;
}
localStorage.setItem('slack_token', token);
return new SlackClient(token);
}, [token]);
const logout = useCallback(() => {
setToken('');
localStorage.removeItem('slack_token');
}, [setToken]);
const login = useCallback(() => {
setIsLoggingIn(true);
}, [setIsLoggingIn]);
const onLogin = useCallback(
(nextToken: string) => {
setToken(nextToken);
setIsLoggingIn(false);
},
[setToken, setIsLoggingIn],
);
const cancelLogin = useCallback(() => {
setIsLoggingIn(false);
}, [setIsLoggingIn]);
return (
<SlackClientContext.Provider value={{ client, login, logout }}>
{isLoggingIn && (
<SlackLoginComponent setToken={onLogin} cancel={cancelLogin} />
)}
{children}
</SlackClientContext.Provider>
);
};
export type { SlackLogin };
export { SlackClientContext, SlackClientProvider };

View File

@@ -0,0 +1,62 @@
import { useCallback, useContext, useMemo, useState } from 'react';
import { SlackClientContext } from './context';
import { SlackClient } from './client';
const useSlack = () => {
const context = useContext(SlackClientContext);
if (!context) {
throw new Error('useSlack must be used within a SlackClientProvider');
}
const value = useMemo(
() => ({
client: context.client,
logout: context.logout,
login: context.login,
}),
[context.client, context.logout, context.login],
);
return value;
};
const useSlackQuery = <P, T = unknown>(
query: (client: SlackClient, params: P) => Promise<T>,
) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<unknown | null>(null);
const [data, setData] = useState<T | null>(null);
const { client } = useSlack();
const fetch = useCallback(
async (params: P) => {
if (!client) {
throw new Error('Slack client is not initialized');
}
try {
setLoading(true);
const data = await query(client, params);
setData(data);
return data;
} catch (error) {
setError(error);
} finally {
setLoading(false);
}
},
[client, query],
);
const value = useMemo(
() => ({
loading,
error,
data,
fetch,
}),
[loading, error, data, fetch],
);
return value;
};
export { useSlack, useSlackQuery };

View File

@@ -0,0 +1,5 @@
export { SlackClientProvider, SlackLogin } from './context';
export { SlackClient } from './client';
export { useSlack, useSlackQuery } from './hooks';
export { withSlack } from './with-slack';
export * as SlackTypes from './types';

View File

@@ -0,0 +1,5 @@
import { ReturnFromString } from './client';
type Profile = Exclude<ReturnFromString<'users.info'>['user'], undefined>;
export type { Profile };

View File

@@ -0,0 +1,19 @@
import { useSlack } from '.';
const withSlack = <TProps extends object>(
Component: React.ComponentType<TProps>,
Fallback: React.ComponentType<object>,
) => {
const WrappedComponent: React.FC<TProps> = (props) => {
const slack = useSlack();
if (!slack.client) {
return <Fallback />;
}
return <Component {...props} />;
};
return WrappedComponent;
};
export { withSlack };

View File

@@ -0,0 +1,42 @@
import { useCallback, useEffect, useRef } from 'react';
type AutoUpdateOptions<TReturn> = {
interval: number;
action: () => Promise<TReturn>;
callback?: (next: TReturn, prev?: TReturn) => void;
};
const useAutoUpdate = <T>(
{ interval, action, callback = () => {} }: AutoUpdateOptions<T>,
deps: any[],
) => {
const prev = useRef<T>();
const actionWithCallback = useCallback(action, [...deps]);
const callbackWithCallback = useCallback(callback, [...deps]);
const update = useCallback(async () => {
const next = await actionWithCallback();
callbackWithCallback(next, prev.current);
prev.current = next;
}, [actionWithCallback, callbackWithCallback]);
useEffect(() => {
let intervalId: NodeJS.Timeout;
const update = async () => {
const next = await actionWithCallback();
callbackWithCallback(next, prev.current);
prev.current = next;
intervalId = setTimeout(update, interval);
};
update();
return () => {
clearTimeout(intervalId);
};
}, [interval, actionWithCallback, callbackWithCallback]);
return update;
};
export { useAutoUpdate };

View File

@@ -0,0 +1,6 @@
export * from './clients';
export * from './widgets';
export * from './provider';
export * from './notifications';
export * from './hooks';
export * from './boards';

View File

@@ -0,0 +1,65 @@
import { createContext, useCallback, useMemo, useState } from 'react';
import type { Notification } from './types';
type NotificationsContextValue = {
notifications: Notification[];
add: (notification: Notification) => void;
dismiss: (id: string) => void;
};
type NotificationsProviderProps = {
children: React.ReactNode;
};
const NotificationsContext = createContext<NotificationsContextValue | null>(
null,
);
let nextId = 0;
const NotificationsProvider: React.FC<NotificationsProviderProps> = ({
children,
}) => {
const [notifications, setNotifications] = useState<Notification[]>([]);
const add = useCallback((notification: Notification) => {
setNotifications((current) => [
...current,
{
id: String(nextId++),
...notification,
},
]);
if ('Notification' in window) {
const notify = async () => {
if (Notification.permission !== 'granted') {
await Notification.requestPermission();
}
if (Notification.permission === 'granted') {
const n = new Notification(notification.title || 'Notification', {
body: notification.message,
});
setTimeout(() => n.close(), 10 * 1000);
}
};
notify();
}
}, []);
const dismiss = useCallback((id: string) => {
setNotifications((current) => current.filter((n) => n.id !== id));
}, []);
const value = useMemo(
() => ({ notifications, add, dismiss }),
[notifications, add, dismiss],
);
return (
<NotificationsContext.Provider value={value}>
{children}
</NotificationsContext.Provider>
);
};
export { NotificationsContext, NotificationsProvider };

View File

@@ -0,0 +1,37 @@
import { useContext } from 'react';
import { NotificationsContext } from './context';
const useNotifications = () => {
const context = useContext(NotificationsContext);
if (!context) {
throw new Error(
'useNotifications must be used within a NotificationsProvider',
);
}
return context.notifications;
};
const useNotificationAdd = () => {
const context = useContext(NotificationsContext);
if (!context) {
throw new Error(
'useNotificationAdd must be used within a NotificationsProvider',
);
}
return context.add;
};
const useNotificationDismiss = () => {
const context = useContext(NotificationsContext);
if (!context) {
throw new Error(
'useNotificationDismiss must be used within a NotificationsProvider',
);
}
return context.dismiss;
};
export { useNotifications, useNotificationAdd, useNotificationDismiss };

View File

@@ -0,0 +1,7 @@
export { NotificationsProvider } from './context';
export type { Notification } from './types';
export {
useNotifications,
useNotificationAdd,
useNotificationDismiss,
} from './hooks';

View File

@@ -0,0 +1,12 @@
type Notification = {
view: Symbol;
id?: string;
title?: string;
message: string;
actions?: {
label: string;
callback: () => void;
}[];
};
export type { Notification };

View File

@@ -0,0 +1,40 @@
import { TSchema } from '@sinclair/typebox';
import {
ClientProvider,
GithubLogin,
LinearLogin,
SlackLogin,
} from './clients';
import { Widget, WidgetsProvider } from './widgets';
import { NotificationsProvider } from './notifications';
import { BoardsLoad, BoardsProvider, BoardsSave } from './boards';
type DashboardProviderProps = {
children: React.ReactNode;
widgets?: Widget<TSchema>[];
load: BoardsLoad;
save: BoardsSave;
logins: {
github: GithubLogin;
linear: LinearLogin;
slack: SlackLogin;
};
};
const DashboardProvider: React.FC<DashboardProviderProps> = ({
children,
widgets,
load,
save,
logins,
}) => (
<WidgetsProvider widgets={widgets}>
<BoardsProvider load={load} save={save}>
<NotificationsProvider>
<ClientProvider logins={logins}>{children}</ClientProvider>
</NotificationsProvider>
</BoardsProvider>
</WidgetsProvider>
);
export { DashboardProvider };

View File

@@ -0,0 +1,16 @@
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
// expands object types recursively
type ExpandRecursively<T> = T extends object
? T extends infer O
? { [K in keyof O]: ExpandRecursively<O[K]> }
: never
: T;
type AsyncResponse<T> = T extends () => Promise<infer U> ? U : never;
type FirstParameter<T> = T extends (arg1: infer U, ...args: any[]) => any
? U
: never;
export type { Expand, ExpandRecursively, AsyncResponse, FirstParameter };

View File

@@ -0,0 +1,29 @@
import { TSchema } from '@sinclair/typebox';
import { createContext, useState } from 'react';
import { Widget } from './types';
type WidgetsContextValue = {
widgets: Widget<TSchema>[];
};
type WidgetsProviderProps = {
children: React.ReactNode;
widgets?: Widget<TSchema>[];
};
const WidgetsContext = createContext<WidgetsContextValue | null>(null);
const WidgetsProvider: React.FC<WidgetsProviderProps> = ({
children,
widgets: initialWidgets,
}) => {
const [widgets] = useState<Widget<TSchema>[]>(initialWidgets || []);
return (
<WidgetsContext.Provider value={{ widgets }}>
{children}
</WidgetsContext.Provider>
);
};
export { WidgetsContext, WidgetsProvider };

View File

@@ -0,0 +1,26 @@
import { useWidget } from '.';
import { useWidgetData, useWidgetId } from './hooks';
type WidgetRenderProps = {
onSave: (data: any) => void;
};
const WidgetEditor: React.FC<WidgetRenderProps> = ({ onSave }) => {
const id = useWidgetId();
const data = useWidgetData();
const widget = useWidget(id);
if (!widget) {
return null;
}
const Component = widget.edit;
if (!Component) {
return null;
}
return <Component value={data} save={onSave} />;
};
export { WidgetEditor };

View File

@@ -0,0 +1,124 @@
import { useCallback, useContext, useMemo, useState } from 'react';
import { WidgetsContext } from './context';
import { WidgetContext } from './widget-context';
const useWidget = (id: string) => {
const context = useContext(WidgetsContext);
if (!context) {
throw new Error('useWidget must be used within a WidgetsProvider');
}
const current = useMemo(() => {
return context.widgets.find((widget) => widget.id === id);
}, [context.widgets, id]);
return current;
};
const useWidgets = () => {
const context = useContext(WidgetsContext);
if (!context) {
throw new Error('useWidgets must be used within a WidgetsProvider');
}
return context.widgets;
};
type WidgetResult = {
id: string;
data: any;
description?: string;
name: string;
icon?: React.ReactNode;
};
const useGetWidgetsFromUrl = () => {
const [current, setCurrent] = useState<WidgetResult[]>([]);
const widgets = useWidgets();
const update = useCallback(
(url: URL) => {
const result = widgets.map((widget) => {
const parsed = widget.parseUrl?.(url);
if (!parsed) {
return null;
}
return {
id: widget.id,
name: widget.name,
description: widget.description,
icon: widget.icon,
data: parsed,
};
});
setCurrent(result.filter(Boolean) as WidgetResult[]);
},
[widgets],
);
return [current, update] as const;
};
const useDismissWidgetNotification = () => {
const context = useContext(WidgetContext);
if (!context) {
throw new Error(
'useDismissWidgetNotification must be used within a WidgetProvider',
);
}
return context.dismissNotification;
};
const useAddWidgetNotification = () => {
const context = useContext(WidgetContext);
if (!context) {
throw new Error(
'useAddWidgetNotification must be used within a WidgetProvider',
);
}
return context.addNotification;
};
const useWidgetNotifications = () => {
const context = useContext(WidgetContext);
if (!context) {
throw new Error(
'useWidgetNotifications must be used within a WidgetProvider',
);
}
return context.notifications;
};
const useWidgetData = () => {
const context = useContext(WidgetContext);
if (!context) {
throw new Error('useWidgetData must be used within a WidgetProvider');
}
return context.data;
};
const useSetWidgetData = () => {
const context = useContext(WidgetContext);
if (!context) {
throw new Error('useSetWidgetData must be used within a WidgetProvider');
}
return context.setData;
};
const useWidgetId = () => {
const context = useContext(WidgetContext);
if (!context) {
throw new Error('useWidgetId must be used within a WidgetProvider');
}
return context.id;
};
export {
useWidget,
useWidgets,
useGetWidgetsFromUrl,
useWidgetNotifications,
useDismissWidgetNotification,
useAddWidgetNotification,
useWidgetData,
useSetWidgetData,
useWidgetId,
};

View File

@@ -0,0 +1,16 @@
export { WidgetsProvider } from './context';
export type { Widget } from './types';
export {
useWidget,
useWidgets,
useGetWidgetsFromUrl,
useAddWidgetNotification,
useDismissWidgetNotification,
useWidgetNotifications,
useWidgetId,
useWidgetData,
useSetWidgetData,
} from './hooks';
export { WidgetProvider } from './widget-context';
export { WidgetView } from './view';
export { WidgetEditor } from './editor';

View File

@@ -0,0 +1,17 @@
import { TSchema, Static } from '@sinclair/typebox';
type Widget<TConfig extends TSchema> = {
name: string;
id: string;
description?: string;
icon?: React.ReactNode;
schema: TSchema;
parseUrl?: (url: URL) => Static<TConfig> | undefined;
component: React.ComponentType<Static<TConfig>>;
edit?: React.ComponentType<{
value?: Static<TConfig>;
save: (next: Static<TConfig>) => void;
}>;
};
export type { Widget };

View File

@@ -0,0 +1,18 @@
import { useWidget } from '.';
import { useWidgetData, useWidgetId } from './hooks';
const WidgetView: React.FC = () => {
const id = useWidgetId();
const data = useWidgetData();
const widget = useWidget(id);
if (!widget) {
return null;
}
const Component = widget.component;
return <Component {...data} />;
};
export { WidgetView };

View File

@@ -0,0 +1,73 @@
import { createContext, useCallback, useMemo, useRef } from 'react';
import {
Notification as BaseNotification,
useNotificationAdd,
useNotifications,
useNotificationDismiss,
} from '../notifications';
type Notification = Omit<BaseNotification, 'view'>;
type WidgetContextValue = {
id: string;
data?: any;
addNotification: (notification: Notification) => void;
dismissNotification: (id: string) => void;
notifications: Notification[];
setData?: (data: any) => void;
};
type WidgetProviderProps = {
id: string;
data?: any;
setData?: (data: any) => void;
children: React.ReactNode;
};
const WidgetContext = createContext<WidgetContextValue | null>(null);
const WidgetProvider = ({
id,
data,
setData,
children,
}: WidgetProviderProps) => {
const ref = useRef(Symbol('WidgetRender'));
const globalNotifications = useNotifications();
const addGlobalNotification = useNotificationAdd();
const dissmissGlobalNotification = useNotificationDismiss();
const notifications = useMemo(() => {
return globalNotifications.filter((n) => n.view !== ref.current);
}, [globalNotifications]);
const addNotification = useCallback(
(notification: Notification) => {
addGlobalNotification({ ...notification, view: ref.current });
},
[addGlobalNotification],
);
const dismissNotification = useCallback(
(dismissId: string) => {
dissmissGlobalNotification(dismissId);
},
[dissmissGlobalNotification],
);
const value = useMemo(
() => ({
addNotification,
dismissNotification,
notifications,
id,
data,
setData,
}),
[addNotification, notifications, id, data, setData, dismissNotification],
);
return (
<WidgetContext.Provider value={value}>{children}</WidgetContext.Provider>
);
};
export { WidgetContext, WidgetProvider };