mirror of
https://github.com/ajnart/homarr.git
synced 2026-05-06 05:16:43 +02:00
✨ Add addition of widgets on board
This commit is contained in:
@@ -61,6 +61,7 @@ export const EditAppModal = ({
|
||||
|
||||
const onSubmit = (values: FormType) => {
|
||||
createOrUpdateApp({ app: values });
|
||||
console.log(values);
|
||||
// also close the parent modal
|
||||
context.closeAll();
|
||||
};
|
||||
|
||||
@@ -24,7 +24,6 @@ export const useAppActions = ({ boardName }: { boardName: string }) => {
|
||||
sectionId = prev.sections
|
||||
.filter((section): section is EmptySection => section.type === 'empty')
|
||||
.sort((a, b) => a.position - b.position)[0].id;
|
||||
console.log(sectionId);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -32,9 +31,9 @@ export const useAppActions = ({ boardName }: { boardName: string }) => {
|
||||
sections: prev.sections.map((section) => {
|
||||
// Return same section if item is not in it
|
||||
if (section.id !== sectionId) return section;
|
||||
console.log(section);
|
||||
return {
|
||||
...section,
|
||||
// Width, height, x, y are defined by gridstack afterwards
|
||||
items: section.items.filter((item) => item.id !== app.id).concat(app as AppItem),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -18,8 +18,8 @@ import { ContextModalProps } from '@mantine/modals';
|
||||
import { IconAlertTriangle, IconPlaylistX, IconPlus } from '@tabler/icons-react';
|
||||
import { Trans, useTranslation } from 'next-i18next';
|
||||
import { FC, useState } from 'react';
|
||||
import { useWidgetActions } from '~/components/Board/Items/Widget/widget-actions';
|
||||
import { WidgetItem } from '~/components/Board/context';
|
||||
import { useWidgetActions } from '~/components/Board/widget-actions';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useConfigStore } from '~/config/store';
|
||||
import { mapObject } from '~/tools/client/objects';
|
||||
|
||||
105
src/components/Board/Items/Widget/widget-actions.ts
Normal file
105
src/components/Board/Items/Widget/widget-actions.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useCallback } from 'react';
|
||||
import { v4 } from 'uuid';
|
||||
import { z } from 'zod';
|
||||
import { api } from '~/utils/api';
|
||||
import { widgetCreationSchema, widgetSortSchema } from '~/validations/widget';
|
||||
import { IWidget, IWidgetDefinition } from '~/widgets/widgets';
|
||||
|
||||
import { EmptySection, WidgetItem } from '../../context';
|
||||
|
||||
type UpdateWidgetOptions = {
|
||||
itemId: string;
|
||||
newOptions: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type CreateWidget = {
|
||||
sort: z.infer<typeof widgetSortSchema>;
|
||||
definition: IWidgetDefinition;
|
||||
};
|
||||
|
||||
export const useWidgetActions = ({ boardName }: { boardName: string }) => {
|
||||
const utils = api.useContext();
|
||||
const updateWidgetOptions = useCallback(
|
||||
({ itemId, newOptions }: UpdateWidgetOptions) => {
|
||||
utils.boards.byName.setData({ boardName }, (prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
sections: prev.sections.map((section) => {
|
||||
// Return same section if item is not in it
|
||||
if (!section.items.some((item) => item.id === itemId)) return section;
|
||||
return {
|
||||
...section,
|
||||
items: section.items.map((item) => {
|
||||
// Return same item if item is not the one we're moving
|
||||
if (item.id !== itemId || item.type !== 'widget') return item;
|
||||
return {
|
||||
...item,
|
||||
options: newOptions,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
[boardName, utils]
|
||||
);
|
||||
|
||||
const createWidget = useCallback(
|
||||
({ sort, definition }: CreateWidget) => {
|
||||
utils.boards.byName.setData({ boardName }, (prev) => {
|
||||
if (!prev) return prev;
|
||||
|
||||
let lastSection = prev.sections
|
||||
.filter((s): s is EmptySection => s.type === 'empty')
|
||||
.sort((a, b) => a.position - b.position)[0];
|
||||
|
||||
const widget = {
|
||||
id: v4(),
|
||||
type: 'widget',
|
||||
sort,
|
||||
options: Object.entries(definition.options).reduce(
|
||||
(prev, [k, v]) => {
|
||||
const newPrev = prev;
|
||||
newPrev[k] = v.defaultValue;
|
||||
return newPrev;
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
),
|
||||
} satisfies z.infer<typeof widgetCreationSchema>;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
sections: prev.sections.map((section) => {
|
||||
// Return same section if item is not in it
|
||||
if (section.id !== lastSection.id) return section;
|
||||
return {
|
||||
...section,
|
||||
items: section.items.concat(applyMinSize(widget, definition)),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
[boardName, utils]
|
||||
);
|
||||
|
||||
return {
|
||||
createWidget,
|
||||
updateWidgetOptions,
|
||||
};
|
||||
};
|
||||
|
||||
// TODO: When section size is declared it should be used to calculate the min size
|
||||
const applyMinSize = (
|
||||
widget: z.infer<typeof widgetCreationSchema>,
|
||||
definition: IWidgetDefinition
|
||||
): WidgetItem => {
|
||||
// Width, height, x, y are defined by gridstack afterwards
|
||||
return {
|
||||
...widget,
|
||||
width: definition.gridstack.minWidth,
|
||||
height: definition.gridstack.minHeight,
|
||||
} as WidgetItem;
|
||||
};
|
||||
@@ -13,7 +13,13 @@ export const SelectElementModal = ({ id, innerProps }: ContextModalProps<InnerPr
|
||||
const [activeTab, setActiveTab] = useState<undefined | 'integrations'>();
|
||||
|
||||
if (activeTab === 'integrations') {
|
||||
return <AvailableIntegrationElements onClickBack={() => setActiveTab(undefined)} />;
|
||||
return (
|
||||
<AvailableIntegrationElements
|
||||
modalId={id}
|
||||
boardName={innerProps.board.name}
|
||||
onClickBack={() => setActiveTab(undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { Grid, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { objectEntries } from '~/tools/object';
|
||||
|
||||
import widgets from '../../../../widgets';
|
||||
import { SelectorBackArrow } from '../Shared/SelectorBackArrow';
|
||||
import { WidgetElementType } from './WidgetElementType';
|
||||
|
||||
interface AvailableIntegrationElementsProps {
|
||||
modalId: string;
|
||||
boardName: string;
|
||||
onClickBack: () => void;
|
||||
}
|
||||
|
||||
export const AvailableIntegrationElements = ({
|
||||
modalId,
|
||||
boardName,
|
||||
onClickBack,
|
||||
}: AvailableIntegrationElementsProps) => {
|
||||
const { t } = useTranslation('layout/element-selector/selector');
|
||||
@@ -22,8 +27,15 @@ export const AvailableIntegrationElements = ({
|
||||
</Text>
|
||||
|
||||
<Grid>
|
||||
{Object.entries(widgets).map(([k, v]) => (
|
||||
<WidgetElementType key={k} id={k} image={v.icon} widget={v} />
|
||||
{objectEntries(widgets).map(([k, v]) => (
|
||||
<WidgetElementType
|
||||
key={k}
|
||||
sort={k}
|
||||
boardName={boardName}
|
||||
image={v.icon}
|
||||
widget={v}
|
||||
modalId={modalId}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
|
||||
@@ -2,95 +2,39 @@ import { useModals } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { Icon, IconChecks } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { useConfigStore } from '~/config/store';
|
||||
import { IWidget, IWidgetDefinition } from '~/widgets/widgets';
|
||||
import { WidgetSort } from '~/server/db/items';
|
||||
import { IWidgetDefinition } from '~/widgets/widgets';
|
||||
|
||||
import { useEditModeStore } from '../../useEditModeStore';
|
||||
import { useWidgetActions } from '../../Items/Widget/widget-actions';
|
||||
import { GenericAvailableElementType } from '../Shared/GenericElementType';
|
||||
|
||||
interface WidgetElementTypeProps {
|
||||
id: string;
|
||||
sort: WidgetSort;
|
||||
boardName: string;
|
||||
image: string | Icon;
|
||||
disabled?: boolean;
|
||||
widget: IWidgetDefinition;
|
||||
modalId: string;
|
||||
}
|
||||
|
||||
export const WidgetElementType = ({ id, image, disabled, widget }: WidgetElementTypeProps) => {
|
||||
export const WidgetElementType = ({
|
||||
sort,
|
||||
image,
|
||||
disabled,
|
||||
widget,
|
||||
boardName,
|
||||
modalId,
|
||||
}: WidgetElementTypeProps) => {
|
||||
const { closeModal } = useModals();
|
||||
const { t } = useTranslation(`modules/${id}`);
|
||||
const { name: configName, config } = useConfigContext();
|
||||
const updateConfig = useConfigStore((x) => x.updateConfig);
|
||||
const isEditMode = useEditModeStore((x) => x.enabled);
|
||||
|
||||
if (!configName) return null;
|
||||
|
||||
const getLowestWrapper = () => config?.wrappers.sort((a, b) => a.position - b.position)[0];
|
||||
const { t } = useTranslation(`modules/${sort}`);
|
||||
const { createWidget } = useWidgetActions({ boardName });
|
||||
|
||||
const handleAddition = async () => {
|
||||
updateConfig(
|
||||
configName,
|
||||
(prev) => ({
|
||||
...prev,
|
||||
widgets: [
|
||||
...prev.widgets,
|
||||
{
|
||||
id: uuidv4(),
|
||||
type: widget.id,
|
||||
properties: Object.entries(widget.options).reduce(
|
||||
(prev, [k, v]) => {
|
||||
const newPrev = prev;
|
||||
newPrev[k] = v.defaultValue;
|
||||
return newPrev;
|
||||
},
|
||||
{} as IWidget<string, any>['properties']
|
||||
),
|
||||
area: {
|
||||
type: 'wrapper',
|
||||
properties: {
|
||||
id: getLowestWrapper()?.id ?? '',
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
sm: {
|
||||
location: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
size: {
|
||||
width: widget.gridstack.minWidth,
|
||||
height: widget.gridstack.minHeight,
|
||||
},
|
||||
},
|
||||
md: {
|
||||
location: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
size: {
|
||||
width: widget.gridstack.minWidth,
|
||||
height: widget.gridstack.minHeight,
|
||||
},
|
||||
},
|
||||
lg: {
|
||||
location: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
size: {
|
||||
width: widget.gridstack.minWidth,
|
||||
height: widget.gridstack.minHeight,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
!isEditMode
|
||||
);
|
||||
closeModal('selectElement');
|
||||
createWidget({
|
||||
sort,
|
||||
definition: widget,
|
||||
});
|
||||
closeModal(modalId);
|
||||
showNotification({
|
||||
title: t('descriptor.name'),
|
||||
message: t('descriptor.description'),
|
||||
|
||||
@@ -63,5 +63,3 @@ type ItemOfType<TItem extends Item, TItemType extends Item['type']> = TItem exte
|
||||
: never;
|
||||
export type AppItem = ItemOfType<Item, 'app'>;
|
||||
export type WidgetItem = ItemOfType<Item, 'widget'>;
|
||||
|
||||
export type IntegrationSecret = Exclude<AppItem['integration'], null>['secrets'][number];
|
||||
|
||||
@@ -51,20 +51,11 @@ export const initializeGridstack = ({
|
||||
grid.removeAll(false);
|
||||
section.items.forEach((item) => {
|
||||
const ref = refs.items.current[item.id]?.current;
|
||||
setAttributesFromShape(ref, item);
|
||||
ref && grid.makeWidget(ref);
|
||||
});
|
||||
grid.batchUpdate(false);
|
||||
};
|
||||
|
||||
function setAttributesFromShape(ref: GridItemHTMLElement | null, item: Item) {
|
||||
if (!item || !ref) return;
|
||||
ref.setAttribute('gs-x', item.x.toString());
|
||||
ref.setAttribute('gs-y', item.y.toString());
|
||||
ref.setAttribute('gs-w', item.width.toString());
|
||||
ref.setAttribute('gs-h', item.height.toString());
|
||||
}
|
||||
|
||||
export type ItemWithUnknownLocation = {
|
||||
x?: number;
|
||||
y?: number;
|
||||
|
||||
@@ -91,7 +91,9 @@ export const useGridstack = ({ section }: UseGridstackProps): UseGristackReturnT
|
||||
const onAdd = (addedNode: GridStackNode) => {
|
||||
const itemType = addedNode.el?.getAttribute('data-type');
|
||||
const itemId = addedNode.el?.getAttribute('data-id');
|
||||
console.log('onAdd', itemType, itemId);
|
||||
if (!itemType || !itemId) return;
|
||||
console.log('onAdd', addedNode);
|
||||
|
||||
// Updates the react-query state
|
||||
moveItemToSection({
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
type UpdateWidgetOptions = {
|
||||
itemId: string;
|
||||
newOptions: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const useWidgetActions = ({ boardName }: { boardName: string }) => {
|
||||
const utils = api.useContext();
|
||||
const updateWidgetOptions = useCallback(
|
||||
({ itemId, newOptions }: UpdateWidgetOptions) => {
|
||||
utils.boards.byName.setData({ boardName }, (prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
sections: prev.sections.map((section) => {
|
||||
// Return same section if item is not in it
|
||||
if (!section.items.some((item) => item.id === itemId)) return section;
|
||||
return {
|
||||
...section,
|
||||
items: section.items.map((item) => {
|
||||
// Return same item if item is not the one we're moving
|
||||
if (item.id !== itemId || item.type !== 'widget') return item;
|
||||
return {
|
||||
...item,
|
||||
options: newOptions,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
[boardName, utils]
|
||||
);
|
||||
|
||||
return {
|
||||
updateWidgetOptions,
|
||||
};
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { eq, inArray } from 'drizzle-orm';
|
||||
import fs from 'fs';
|
||||
import { z } from 'zod';
|
||||
import { db } from '~/server/db';
|
||||
import { LayoutKind, WidgetType } from '~/server/db/items';
|
||||
import { LayoutKind, WidgetSort } from '~/server/db/items';
|
||||
import { getDefaultBoardAsync } from '~/server/db/queries/userSettings';
|
||||
import {
|
||||
appItems,
|
||||
@@ -396,7 +396,7 @@ const addLayoutAsync = async (props: AddLayoutProps) => {
|
||||
type AddWidgetProps = {
|
||||
boardId: string;
|
||||
sectionId: string;
|
||||
type: WidgetType;
|
||||
type: WidgetSort;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
@@ -483,11 +483,6 @@ const getAppsForSectionsAsync = async (sectionIds: string[]) => {
|
||||
with: {
|
||||
app: {
|
||||
with: {
|
||||
integration: {
|
||||
with: {
|
||||
secrets: true,
|
||||
},
|
||||
},
|
||||
statusCodes: {
|
||||
columns: {
|
||||
code: true,
|
||||
@@ -561,7 +556,6 @@ type FullBoardWithLayout = Exclude<
|
||||
type MapSection = FullBoardWithLayout['layouts'][number]['sections'][number];
|
||||
type MapApp = Awaited<ReturnType<typeof getAppsForSectionsAsync>>[number];
|
||||
type MapWidget = Awaited<ReturnType<typeof getWidgetsForSectionsAsync>>[number];
|
||||
type MapSecret = Exclude<MapApp['app']['integration'], null>['secrets'][number];
|
||||
type MapOption = MapWidget['options'][number];
|
||||
|
||||
const mapSection = (
|
||||
@@ -625,40 +619,16 @@ const mapApp = (appItem: MapApp) => {
|
||||
const { sectionId, itemId, id, ...commonLayoutItem } = appItem.item.layouts.at(0)!;
|
||||
const common = { ...commonLayoutItem, id: itemId };
|
||||
const { app: innerApp, appId, itemId: _itemId, item, ...otherAppItem } = appItem;
|
||||
const { id: _id, integration, statusCodes, integrationId, ...app } = appItem.app!;
|
||||
const { id: _id, statusCodes, ...app } = appItem.app!;
|
||||
return {
|
||||
...common,
|
||||
...otherAppItem,
|
||||
...app,
|
||||
type: 'app' as const,
|
||||
integration: integration
|
||||
? {
|
||||
...integration,
|
||||
secrets: integration.secrets.map(mapSecret),
|
||||
}
|
||||
: null,
|
||||
statusCodes: statusCodes.map((x) => x.code),
|
||||
};
|
||||
};
|
||||
|
||||
const mapSecret = ({ integrationId, ...secret }: MapSecret) => {
|
||||
const isDefined = secret.value !== null && secret.value !== '';
|
||||
if (secret.visibility === 'private') {
|
||||
return {
|
||||
...secret,
|
||||
visibility: 'private' as const,
|
||||
isDefined,
|
||||
value: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...secret,
|
||||
visibility: 'public' as const,
|
||||
isDefined,
|
||||
};
|
||||
};
|
||||
|
||||
const mapOptions = (options: MapOption[]) => {
|
||||
const result = {} as Record<string, unknown>;
|
||||
const sorted = options.sort((a, b) => a.path.localeCompare(b.path));
|
||||
|
||||
@@ -39,7 +39,7 @@ export const integrationSecrets = {
|
||||
icon: IconPassword,
|
||||
},
|
||||
} satisfies Record<string, IntegrationSecretDefinition>;
|
||||
export const widgetTypes = objectKeys(widgets);
|
||||
export const widgetSorts = objectKeys(widgets);
|
||||
export const widgetOptionTypes = [
|
||||
'string',
|
||||
'number',
|
||||
@@ -157,7 +157,7 @@ export type FirstDayOfWeek = (typeof firstDaysOfWeek)[number];
|
||||
export type IntegrationType = keyof typeof integrationTypes;
|
||||
export type IntegrationSecretVisibility = (typeof integrationSecretVisibility)[number];
|
||||
export type IntegrationSecretKey = keyof typeof integrationSecrets;
|
||||
export type WidgetType = (typeof widgetTypes)[number];
|
||||
export type WidgetSort = (typeof widgetSorts)[number];
|
||||
export type WidgetOptionType = (typeof widgetOptionTypes)[number];
|
||||
export type AppNamePosition = (typeof appNamePositions)[number];
|
||||
export type AppNameStyle = (typeof appNameStyles)[number];
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SectionType,
|
||||
StatusCodeType,
|
||||
WidgetOptionType,
|
||||
WidgetType,
|
||||
WidgetSort,
|
||||
} from './items';
|
||||
|
||||
export const users = sqliteTable('user', {
|
||||
@@ -161,7 +161,7 @@ export const integrationSecrets = sqliteTable(
|
||||
|
||||
export const widgets = sqliteTable('widget', {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
type: text('type').$type<WidgetType>().notNull(),
|
||||
type: text('type').$type<WidgetSort>().notNull(),
|
||||
itemId: text('item_id')
|
||||
.notNull()
|
||||
.references(() => items.id, { onDelete: 'cascade' }),
|
||||
@@ -197,7 +197,6 @@ export const apps = sqliteTable('app', {
|
||||
internalUrl: text('internal_url').notNull(),
|
||||
externalUrl: text('external_url'),
|
||||
iconUrl: text('icon_url').notNull(),
|
||||
integrationId: text('integration_id').references(() => integrations.id, { onDelete: 'cascade' }),
|
||||
});
|
||||
|
||||
export const appItems = sqliteTable('app_item', {
|
||||
@@ -344,10 +343,6 @@ export const integrationRelations = relations(integrations, ({ many }) => ({
|
||||
}));
|
||||
|
||||
export const appRelations = relations(apps, ({ one, many }) => ({
|
||||
integration: one(integrations, {
|
||||
fields: [apps.integrationId],
|
||||
references: [integrations.id],
|
||||
}),
|
||||
statusCodes: many(appStatusCodes),
|
||||
items: many(appItems),
|
||||
}));
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { AppItem } from '~/components/Board/context';
|
||||
import { z } from 'zod';
|
||||
import { appFormSchema } from '~/components/Board/Items/App/EditAppModal';
|
||||
import { AppType } from '~/types/app';
|
||||
|
||||
export const generateDefaultApp2 = (): AppItem => {
|
||||
export const generateDefaultApp2 = (): z.infer<typeof appFormSchema> => {
|
||||
const appId = uuidv4();
|
||||
return {
|
||||
id: appId,
|
||||
@@ -19,10 +20,6 @@ export const generateDefaultApp2 = (): AppItem => {
|
||||
statusCodes: [200, 301, 302, 304, 307, 308],
|
||||
openInNewTab: true,
|
||||
description: null,
|
||||
height: 1,
|
||||
width: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
20
src/validations/widget.ts
Normal file
20
src/validations/widget.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod';
|
||||
import { widgetSorts } from '~/server/db/items';
|
||||
|
||||
export const widgetSortSchema = z.enum([widgetSorts[0], ...widgetSorts.slice(1)]);
|
||||
|
||||
export const widgetCreationSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.literal('widget'),
|
||||
sort: widgetSortSchema,
|
||||
options: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
export const widgetSchema = z
|
||||
.object({
|
||||
x: z.number().min(0),
|
||||
y: z.number().min(0),
|
||||
width: z.number().min(0),
|
||||
height: z.number().min(0),
|
||||
})
|
||||
.merge(widgetCreationSchema);
|
||||
Reference in New Issue
Block a user