Add addition of widgets on board

This commit is contained in:
Meier Lukas
2023-10-21 11:38:51 +02:00
parent 7f80aa6463
commit fd4dbac787
16 changed files with 182 additions and 183 deletions

View File

@@ -61,6 +61,7 @@ export const EditAppModal = ({
const onSubmit = (values: FormType) => {
createOrUpdateApp({ app: values });
console.log(values);
// also close the parent modal
context.closeAll();
};

View File

@@ -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),
};
}),

View File

@@ -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';

View 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;
};

View File

@@ -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 (

View File

@@ -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>
</>

View File

@@ -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'),

View File

@@ -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];

View File

@@ -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;

View File

@@ -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({

View File

@@ -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,
};
};

View File

@@ -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));

View File

@@ -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];

View File

@@ -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),
}));

View File

@@ -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
View 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);