feat: add board (#15)

* wip: Add gridstack board
* wip: Centralize board pages, Add board settings page
* fix: remove cyclic dependency and rename widget-sort to kind
* improve: Add header actions as parallel route
* feat: add item select modal, add category edit modal,
* feat: add edit item modal
* feat: add remove item modal
* wip: add category actions
* feat: add saving of board, wip: add app widget
* Merge branch 'main' into add-board
* chore: update turbo dependencies
* chore: update mantine dependencies
* chore: fix typescript errors, lint and format
* feat: add confirm modal to category removal, move items of removed category to above wrapper
* feat: remove app widget to continue in another branch
* feat: add loading spinner until board is initialized
* fix: issue with cellheight of gridstack items
* feat: add translations for board
* fix: issue with translation for settings page
* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-02-03 22:26:12 +01:00
committed by GitHub
parent cfd1c14034
commit 9d520874f4
88 changed files with 3431 additions and 262 deletions

View File

@@ -4,7 +4,6 @@ import type { AppRouter } from "./src/root";
export { appRouter, type AppRouter } from "./src/root";
export { createTRPCContext } from "./src/trpc";
/**
* Inference helpers for input types
* @example type HelloInput = RouterInputs['example']['hello']

View File

@@ -1,6 +1,10 @@
{
"name": "@homarr/api",
"version": "0.1.0",
"exports": {
".": "./index.ts",
"./client": "./src/client.ts"
},
"private": true,
"main": "./index.ts",
"types": "./index.ts",

View File

@@ -0,0 +1,5 @@
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "..";
export const clientApi = createTRPCReact<AppRouter>();

View File

@@ -1,3 +1,4 @@
import { boardRouter } from "./router/board";
import { integrationRouter } from "./router/integration";
import { userRouter } from "./router/user";
import { createTRPCRouter } from "./trpc";
@@ -5,6 +6,7 @@ import { createTRPCRouter } from "./trpc";
export const appRouter = createTRPCRouter({
user: userRouter,
integration: integrationRouter,
board: boardRouter,
});
// export type definition of API

View File

@@ -0,0 +1,290 @@
import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { Database } from "@homarr/db";
import { and, db, eq, inArray } from "@homarr/db";
import {
boards,
integrationItems,
items,
sections,
} from "@homarr/db/schema/sqlite";
import type { WidgetKind } from "@homarr/definitions";
import { widgetKinds } from "@homarr/definitions";
import {
createSectionSchema,
sharedItemSchema,
validation,
z,
} from "@homarr/validation";
import { zodUnionFromArray } from "../../../validation/src/enums";
import type { WidgetComponentProps } from "../../../widgets/src/definition";
import { createTRPCRouter, publicProcedure } from "../trpc";
const filterAddedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
inputArray.filter(
(inputItem) => !dbArray.some((dbItem) => dbItem.id === inputItem.id),
);
const filterRemovedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
dbArray.filter(
(dbItem) => !inputArray.some((inputItem) => dbItem.id === inputItem.id),
);
const filterUpdatedItems = <TInput extends { id: string }>(
inputArray: TInput[],
dbArray: TInput[],
) =>
inputArray.filter((inputItem) =>
dbArray.some((dbItem) => dbItem.id === inputItem.id),
);
export const boardRouter = createTRPCRouter({
default: publicProcedure.query(async ({ ctx }) => {
return await getFullBoardByName(ctx.db, "default");
}),
byName: publicProcedure
.input(validation.board.byName)
.query(async ({ input, ctx }) => {
return await getFullBoardByName(ctx.db, input.name);
}),
saveGeneralSettings: publicProcedure
.input(validation.board.saveGeneralSettings)
.mutation(async ({ input }) => {
await db.update(boards).set(input).where(eq(boards.name, "default"));
}),
save: publicProcedure
.input(validation.board.save)
.mutation(async ({ input, ctx }) => {
await ctx.db.transaction(async (tx) => {
const dbBoard = await getFullBoardByName(tx, input.name);
const addedSections = filterAddedItems(
input.sections,
dbBoard.sections,
);
if (addedSections.length > 0) {
await tx.insert(sections).values(
addedSections.map((section) => ({
id: section.id,
kind: section.kind,
position: section.position,
name: "name" in section ? section.name : null,
boardId: dbBoard.id,
})),
);
}
const inputItems = input.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
const dbItems = dbBoard.sections.flatMap((section) =>
section.items.map((item) => ({ ...item, sectionId: section.id })),
);
const addedItems = filterAddedItems(inputItems, dbItems);
if (addedItems.length > 0) {
await tx.insert(items).values(
addedItems.map((item) => ({
id: item.id,
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
sectionId: item.sectionId,
})),
);
}
const inputIntegrationRelations = inputItems.flatMap(
({ integrations, id: itemId }) =>
integrations.map((integration) => ({
integrationId: integration.id,
itemId,
})),
);
const dbIntegrationRelations = dbItems.flatMap(
({ integrations, id: itemId }) =>
integrations.map((integration) => ({
integrationId: integration.id,
itemId,
})),
);
const addedIntegrationRelations = inputIntegrationRelations.filter(
(inputRelation) =>
!dbIntegrationRelations.some(
(dbRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
if (addedIntegrationRelations.length > 0) {
await tx.insert(integrationItems).values(
addedIntegrationRelations.map((relation) => ({
itemId: relation.itemId,
integrationId: relation.integrationId,
})),
);
}
const updatedItems = filterUpdatedItems(inputItems, dbItems);
for (const item of updatedItems) {
await tx
.update(items)
.set({
kind: item.kind,
height: item.height,
width: item.width,
xOffset: item.xOffset,
yOffset: item.yOffset,
options: superjson.stringify(item.options),
sectionId: item.sectionId,
})
.where(eq(items.id, item.id));
}
const updatedSections = filterUpdatedItems(
input.sections,
dbBoard.sections,
);
for (const section of updatedSections) {
await tx
.update(sections)
.set({
kind: section.kind,
position: section.position,
name: "name" in section ? section.name : null,
})
.where(eq(sections.id, section.id));
}
const removedIntegrationRelations = dbIntegrationRelations.filter(
(dbRelation) =>
!inputIntegrationRelations.some(
(inputRelation) =>
dbRelation.itemId === inputRelation.itemId &&
dbRelation.integrationId === inputRelation.integrationId,
),
);
for (const relation of removedIntegrationRelations) {
await tx
.delete(integrationItems)
.where(
and(
eq(integrationItems.itemId, relation.itemId),
eq(integrationItems.integrationId, relation.integrationId),
),
);
}
const removedItems = filterRemovedItems(inputItems, dbItems);
const itemIds = removedItems.map((item) => item.id);
if (itemIds.length > 0) {
await tx.delete(items).where(inArray(items.id, itemIds));
}
const removedSections = filterRemovedItems(
input.sections,
dbBoard.sections,
);
const sectionIds = removedSections.map((section) => section.id);
if (sectionIds.length > 0) {
await tx.delete(sections).where(inArray(sections.id, sectionIds));
}
});
}),
});
const getFullBoardByName = async (db: Database, name: string) => {
const board = await db.query.boards.findFirst({
where: eq(boards.name, name),
with: {
sections: {
with: {
items: {
with: {
integrations: {
with: {
integration: true,
},
},
},
},
},
},
},
});
if (!board) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Board not found",
});
}
const { sections, ...otherBoardProperties } = board;
return {
...otherBoardProperties,
sections: sections.map((section) =>
parseSection({
...section,
items: section.items.map((item) => ({
...item,
integrations: item.integrations.map((item) => item.integration),
options: superjson.parse<Record<string, unknown>>(item.options),
})),
}),
),
};
};
// The following is a bit of a mess, it's providing us typesafe options matching the widget kind.
// But I might be able to do this in a better way in the future.
const forKind = <T extends WidgetKind>(kind: T) =>
z.object({
kind: z.literal(kind),
options: z.custom<Partial<WidgetComponentProps<T>["options"]>>(),
}) as UnionizeSpecificItemSchemaForWidgetKind<T>;
type SpecificItemSchemaForWidgetKind<TKind extends WidgetKind> = z.ZodObject<{
kind: z.ZodLiteral<TKind>;
options: z.ZodType<
Partial<WidgetComponentProps<TKind>["options"]>,
z.ZodTypeDef,
Partial<WidgetComponentProps<TKind>["options"]>
>;
}>;
type UnionizeSpecificItemSchemaForWidgetKind<T> = T extends WidgetKind
? SpecificItemSchemaForWidgetKind<T>
: never;
const outputItemSchema = zodUnionFromArray(
widgetKinds.map((kind) => forKind(kind)),
).and(sharedItemSchema);
const parseSection = (section: unknown) => {
const result = createSectionSchema(outputItemSchema).safeParse(section);
if (!result.success) {
throw new Error(result.error.message);
}
return result.data;
};

1
packages/db/client.ts Normal file
View File

@@ -0,0 +1 @@
export { createId } from "@paralleldrive/cuid2";

View File

@@ -1,4 +1,5 @@
import Database from "better-sqlite3";
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as sqliteSchema from "./schema/sqlite";
@@ -11,4 +12,6 @@ const sqlite = new Database(process.env.DB_URL!);
export const db = drizzle(sqlite, { schema });
export type Database = BetterSQLite3Database<typeof schema>;
export { createId } from "@paralleldrive/cuid2";

View File

@@ -1,6 +1,11 @@
{
"name": "@homarr/db",
"version": "0.1.0",
"exports": {
".": "./index.ts",
"./client": "./client.ts",
"./schema/sqlite": "./schema/sqlite.ts"
},
"private": true,
"main": "./index.ts",
"types": "./index.ts",

View File

@@ -1,8 +1,10 @@
import type { AdapterAccount } from "@auth/core/adapters";
import type { MantineColor } from "@mantine/core";
import type { InferSelectModel } from "drizzle-orm";
import { relations } from "drizzle-orm";
import {
index,
int,
integer,
primaryKey,
sqliteTable,
@@ -10,8 +12,13 @@ import {
} from "drizzle-orm/sqlite-core";
import type {
BackgroundImageAttachment,
BackgroundImageRepeat,
BackgroundImageSize,
IntegrationKind,
IntegrationSecretKind,
SectionKind,
WidgetKind,
} from "@homarr/definitions";
export const users = sqliteTable("user", {
@@ -107,6 +114,91 @@ export const integrationSecrets = sqliteTable(
}),
);
export const boards = sqliteTable("board", {
id: text("id").notNull().primaryKey(),
name: text("name").notNull(),
isPublic: int("is_public", { mode: "boolean" }).default(false).notNull(),
pageTitle: text("page_title"),
metaTitle: text("meta_title"),
logoImageUrl: text("logo_image_url"),
faviconImageUrl: text("favicon_image_url"),
backgroundImageUrl: text("background_image_url"),
backgroundImageAttachment: text("background_image_attachment")
.$type<BackgroundImageAttachment>()
.default("fixed")
.notNull(),
backgroundImageRepeat: text("background_image_repeat")
.$type<BackgroundImageRepeat>()
.default("no-repeat")
.notNull(),
backgroundImageSize: text("background_image_size")
.$type<BackgroundImageSize>()
.default("cover")
.notNull(),
primaryColor: text("primary_color")
.$type<MantineColor>()
.default("red")
.notNull(),
secondaryColor: text("secondary_color")
.$type<MantineColor>()
.default("orange")
.notNull(),
primaryShade: int("primary_shade").default(6).notNull(),
appOpacity: int("app_opacity").default(100).notNull(),
customCss: text("custom_css"),
showRightSidebar: int("show_right_sidebar", {
mode: "boolean",
})
.default(false)
.notNull(),
showLeftSidebar: int("show_left_sidebar", {
mode: "boolean",
})
.default(false)
.notNull(),
columnCount: int("column_count").default(10).notNull(),
});
export const sections = sqliteTable("section", {
id: text("id").notNull().primaryKey(),
boardId: text("board_id")
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
kind: text("kind").$type<SectionKind>().notNull(),
position: int("position").notNull(),
name: text("name"),
});
export const items = sqliteTable("item", {
id: text("id").notNull().primaryKey(),
sectionId: text("section_id")
.notNull()
.references(() => sections.id, { onDelete: "cascade" }),
kind: text("kind").$type<WidgetKind>().notNull(),
xOffset: int("x_offset").notNull(),
yOffset: int("y_offset").notNull(),
width: int("width").notNull(),
height: int("height").notNull(),
options: text("options").default('{"json": {}}').notNull(), // empty superjson object
});
export const integrationItems = sqliteTable(
"integration_item",
{
itemId: text("item_id")
.notNull()
.references(() => items.id, { onDelete: "cascade" }),
integrationId: text("integration_id")
.notNull()
.references(() => integrations.id, { onDelete: "cascade" }),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.itemId, table.integrationId],
}),
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
@@ -120,6 +212,7 @@ export const userRelations = relations(users, ({ many }) => ({
export const integrationRelations = relations(integrations, ({ many }) => ({
secrets: many(integrationSecrets),
items: many(integrationItems),
}));
export const integrationSecretRelations = relations(
@@ -132,6 +225,40 @@ export const integrationSecretRelations = relations(
}),
);
export const boardRelations = relations(boards, ({ many }) => ({
sections: many(sections),
}));
export const sectionRelations = relations(sections, ({ many, one }) => ({
items: many(items),
board: one(boards, {
fields: [sections.boardId],
references: [boards.id],
}),
}));
export const itemRelations = relations(items, ({ one, many }) => ({
section: one(sections, {
fields: [items.sectionId],
references: [sections.id],
}),
integrations: many(integrationItems),
}));
export const integrationItemRelations = relations(
integrationItems,
({ one }) => ({
integration: one(integrations, {
fields: [integrationItems.integrationId],
references: [integrations.id],
}),
item: one(items, {
fields: [integrationItems.itemId],
references: [items.id],
}),
}),
);
export type User = InferSelectModel<typeof users>;
export type Account = InferSelectModel<typeof accounts>;
export type Session = InferSelectModel<typeof sessions>;

View File

@@ -0,0 +1,13 @@
export const backgroundImageAttachments = ["fixed", "scroll"] as const;
export const backgroundImageRepeats = [
"repeat",
"repeat-x",
"repeat-y",
"no-repeat",
] as const;
export const backgroundImageSizes = ["cover", "contain"] as const;
export type BackgroundImageAttachment =
(typeof backgroundImageAttachments)[number];
export type BackgroundImageRepeat = (typeof backgroundImageRepeats)[number];
export type BackgroundImageSize = (typeof backgroundImageSizes)[number];

View File

@@ -1 +1,4 @@
export * from "./board";
export * from "./integration";
export * from "./section";
export * from "./widget";

View File

@@ -0,0 +1,2 @@
export const sectionKinds = ["category", "empty", "sidebar"] as const;
export type SectionKind = (typeof sectionKinds)[number];

View File

@@ -0,0 +1,2 @@
export const widgetKinds = ["clock", "weather"] as const;
export type WidgetKind = (typeof widgetKinds)[number];

View File

@@ -33,6 +33,6 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@mantine/form": "^7.4.0"
"@mantine/form": "^7.5.1"
}
}

View File

@@ -28,7 +28,7 @@
"typescript": "^5.3.3"
},
"dependencies": {
"@mantine/notifications": "^7.4.0",
"@mantine/notifications": "^7.5.1",
"@homarr/ui": "workspace:^0.1.0"
},
"eslintConfig": {

View File

@@ -34,6 +34,6 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@mantine/spotlight": "^7.4.0"
"@mantine/spotlight": "^7.5.1"
}
}

View File

@@ -3,6 +3,11 @@
import { createI18nClient } from "next-international/client";
import { languageMapping } from "./lang";
import en from "./lang/en";
export const { useI18n, useScopedI18n, I18nProviderClient } =
createI18nClient(languageMapping());
export const { useI18n, useScopedI18n, I18nProviderClient } = createI18nClient(
languageMapping(),
{
fallbackLocale: en,
},
);

View File

@@ -144,6 +144,7 @@ export default {
create: "Create",
edit: "Edit",
save: "Save",
saveChanges: "Save changes",
cancel: "Cancel",
confirm: "Confirm",
},
@@ -156,13 +157,77 @@ export default {
},
noResults: "No results found",
},
widget: {
editModal: {
integrations: {
label: "Integrations",
section: {
category: {
field: {
name: {
label: "Name",
},
},
action: {
create: "New category",
edit: "Rename category",
remove: "Remove category",
moveUp: "Move up",
moveDown: "Move down",
createAbove: "New category above",
createBelow: "New category below",
},
create: {
title: "New category",
submit: "Add category",
},
remove: {
title: "Remove category",
message: "Are you sure you want to remove the category {name}?",
},
edit: {
title: "Rename category",
submit: "Rename category",
},
menu: {
label: {
create: "New category",
changePosition: "Change position",
},
},
},
},
item: {
action: {
create: "New item",
import: "Import item",
edit: "Edit item",
move: "Move item",
remove: "Remove item",
},
menu: {
label: {
settings: "Settings",
dangerZone: "Danger Zone",
},
},
create: {
title: "Choose item to add",
addToBoard: "Add to board",
},
edit: {
title: "Edit item",
field: {
integrations: {
label: "Integrations",
},
},
},
remove: {
title: "Remove item",
message: "Are you sure you want to remove this item?",
},
},
widget: {
clock: {
name: "Date and time",
description: "Displays the current date and time.",
option: {
is24HourFormat: {
label: "24-hour format",
@@ -177,6 +242,9 @@ export default {
},
},
weather: {
name: "Weather",
description:
"Displays the current weather information of a set location.",
option: {
location: {
label: "Location",
@@ -187,6 +255,78 @@ export default {
},
},
},
board: {
action: {
edit: {
notification: {
success: {
title: "Changes applied successfully",
message: "The board was successfully saved",
},
error: {
title: "Unable to apply changes",
message: "The board could not be saved",
},
},
},
},
field: {
pageTitle: {
label: "Page title",
},
metaTitle: {
label: "Meta title",
},
logoImageUrl: {
label: "Logo image URL",
},
faviconImageUrl: {
label: "Favicon image URL",
},
},
setting: {
title: "Settings for {boardName} board",
section: {
general: {
title: "General",
},
layout: {
title: "Layout",
},
appearance: {
title: "Appearance",
},
dangerZone: {
title: "Danger Zone",
action: {
rename: {
label: "Rename board",
description:
"Changing the name will break any links to this board.",
button: "Change name",
},
visibility: {
label: "Change board visibility",
description: {
public: "This board is currently public.",
private: "This board is currently private.",
},
button: {
public: "Make private",
private: "Make public",
},
},
delete: {
label: "Delete this board",
description:
"Once you delete a board, there is no going back. Please be certain.",
button: "Delete this board",
},
},
},
},
},
},
management: {
metaTitle: "Management",
title: {

View File

@@ -1,6 +1,11 @@
import { createI18nServer } from "next-international/server";
import { languageMapping } from "./lang";
import en from "./lang/en";
export const { getI18n, getScopedI18n, getStaticParams } =
createI18nServer(languageMapping());
export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer(
languageMapping(),
{
fallbackLocale: en,
},
);

View File

@@ -35,8 +35,8 @@
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@mantine/core": "^7.4.0",
"@mantine/dates": "^7.4.0",
"@mantine/core": "^7.5.1",
"@mantine/dates": "^7.5.1",
"@tabler/icons-react": "^2.42.0"
}
}

View File

@@ -0,0 +1,43 @@
import { z } from "zod";
import { commonItemSchema, createSectionSchema } from "./shared";
const boardNameSchema = z
.string()
.min(1)
.max(255)
.regex(/^[A-Za-z0-9-\\._]+$/);
const byNameSchema = z.object({
name: boardNameSchema,
});
const saveGeneralSettingsSchema = z.object({
pageTitle: z
.string()
.nullable()
.transform((value) => (value?.trim().length === 0 ? null : value)),
metaTitle: z
.string()
.nullable()
.transform((value) => (value?.trim().length === 0 ? null : value)),
logoImageUrl: z
.string()
.nullable()
.transform((value) => (value?.trim().length === 0 ? null : value)),
faviconImageUrl: z
.string()
.nullable()
.transform((value) => (value?.trim().length === 0 ? null : value)),
});
const saveSchema = z.object({
name: boardNameSchema,
sections: z.array(createSectionSchema(commonItemSchema)),
});
export const boardSchemas = {
byName: byNameSchema,
saveGeneralSettings: saveGeneralSettingsSchema,
save: saveSchema,
};

View File

@@ -1,4 +1,11 @@
import { z } from "zod";
export const zodEnumFromArray = <T extends string>(arr: T[]) =>
z.enum([arr[0]!, ...arr.slice(1)]);
type CouldBeReadonlyArray<T> = T[] | readonly T[];
export const zodEnumFromArray = <T extends string>(
array: CouldBeReadonlyArray<T>,
) => z.enum([array[0]!, ...array.slice(1)]);
export const zodUnionFromArray = <T extends z.ZodTypeAny>(
array: CouldBeReadonlyArray<T>,
) => z.union([array[0]!, array[1]!, ...array.slice(2)]);

View File

@@ -1,7 +1,11 @@
import { boardSchemas } from "./board";
import { integrationSchemas } from "./integration";
import { userSchemas } from "./user";
export const validation = {
user: userSchemas,
integration: integrationSchemas,
board: boardSchemas,
};
export { createSectionSchema, sharedItemSchema } from "./shared";

View File

@@ -0,0 +1,68 @@
import { z } from "zod";
import { integrationKinds, widgetKinds } from "@homarr/definitions";
import { zodEnumFromArray } from "./enums";
export const integrationSchema = z.object({
id: z.string(),
kind: zodEnumFromArray(integrationKinds),
name: z.string(),
url: z.string(),
});
export const sharedItemSchema = z.object({
id: z.string(),
xOffset: z.number(),
yOffset: z.number(),
height: z.number(),
width: z.number(),
integrations: z.array(integrationSchema),
});
export const commonItemSchema = z
.object({
kind: zodEnumFromArray(widgetKinds),
options: z.record(z.unknown()),
})
.and(sharedItemSchema);
const createCategorySchema = <TItemSchema extends z.ZodTypeAny>(
itemSchema: TItemSchema,
) =>
z.object({
id: z.string(),
name: z.string(),
kind: z.literal("category"),
position: z.number(),
items: z.array(itemSchema),
});
const createEmptySchema = <TItemSchema extends z.ZodTypeAny>(
itemSchema: TItemSchema,
) =>
z.object({
id: z.string(),
kind: z.literal("empty"),
position: z.number(),
items: z.array(itemSchema),
});
const createSidebarSchema = <TItemSchema extends z.ZodTypeAny>(
itemSchema: TItemSchema,
) =>
z.object({
id: z.string(),
kind: z.literal("sidebar"),
position: z.union([z.literal(0), z.literal(1)]),
items: z.array(itemSchema),
});
export const createSectionSchema = <TItemSchema extends z.ZodTypeAny>(
itemSchema: TItemSchema,
) =>
z.union([
createCategorySchema(itemSchema),
createEmptySchema(itemSchema),
createSidebarSchema(itemSchema),
]);

View File

@@ -36,6 +36,7 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",

View File

@@ -1,10 +1,10 @@
import type { WidgetKind } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetSort } from "..";
import type { WidgetOptionOfType, WidgetOptionType } from "../options";
export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
sort: WidgetSort;
kind: WidgetKind;
property: string;
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
}
@@ -15,8 +15,8 @@ type UseWidgetInputTranslationReturnType = (
/**
* Short description why as and unknown convertions are used below:
* Typescript was not smart enought to work with the generic of the WidgetSort to only allow properties that are relying within that specified sort.
* This does not mean, that the function useWidgetInputTranslation can be called with invalid arguments without type errors and rather means that the above widget.<sort>.option.<property> string
* Typescript was not smart enought to work with the generic of the WidgetKind to only allow properties that are relying within that specified kind.
* This does not mean, that the function useWidgetInputTranslation can be called with invalid arguments without type errors and rather means that the above widget.<kind>.option.<property> string
* is not recognized as valid argument for the scoped i18n hook. Because the typesafety should remain outside the usage of those methods I (Meierschlumpf) decided to provide this fully typesafe useWidgetInputTranslation method.
*
* Some notes about it:
@@ -24,10 +24,10 @@ type UseWidgetInputTranslationReturnType = (
* is defined for the option. The method does sadly not reconize issues with those definitions. So it does not yell at you when you somewhere show the label without having it defined in the translations.
*/
export const useWidgetInputTranslation = (
sort: WidgetSort,
kind: WidgetKind,
property: string,
): UseWidgetInputTranslationReturnType => {
return useScopedI18n(
`widget.${sort}.option.${property}` as never, // Because the type is complex and not recognized by typescript, we need to cast it to never to make it work.
`widget.${kind}.option.${property}` as never, // Because the type is complex and not recognized by typescript, we need to cast it to never to make it work.
) as unknown as UseWidgetInputTranslationReturnType;
};

View File

@@ -8,10 +8,10 @@ import { useFormContext } from "./form";
export const WidgetMultiSelectInput = ({
property,
sort,
kind,
options,
}: CommonWidgetInputProps<"multiSelect">) => {
const t = useWidgetInputTranslation(sort, property);
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (

View File

@@ -8,10 +8,10 @@ import { useFormContext } from "./form";
export const WidgetNumberInput = ({
property,
sort,
kind,
options,
}: CommonWidgetInputProps<"number">) => {
const t = useWidgetInputTranslation(sort, property);
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (

View File

@@ -8,10 +8,10 @@ import { useFormContext } from "./form";
export const WidgetSelectInput = ({
property,
sort,
kind,
options,
}: CommonWidgetInputProps<"select">) => {
const t = useWidgetInputTranslation(sort, property);
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (

View File

@@ -8,10 +8,10 @@ import { useFormContext } from "./form";
export const WidgetSliderInput = ({
property,
sort,
kind,
options,
}: CommonWidgetInputProps<"slider">) => {
const t = useWidgetInputTranslation(sort, property);
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (

View File

@@ -8,10 +8,10 @@ import { useFormContext } from "./form";
export const WidgetSwitchInput = ({
property,
sort,
kind,
options,
}: CommonWidgetInputProps<"switch">) => {
const t = useWidgetInputTranslation(sort, property);
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (

View File

@@ -8,10 +8,10 @@ import { useFormContext } from "./form";
export const WidgetTextInput = ({
property,
sort: widgetSort,
kind,
options,
}: CommonWidgetInputProps<"text">) => {
const t = useWidgetInputTranslation(widgetSort, property);
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
return (

View File

@@ -1,9 +1,9 @@
import type { LoaderComponent } from "next/dynamic";
import type { IntegrationKind } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { TablerIconsProps } from "@homarr/ui";
import type { WidgetImports, WidgetSort } from ".";
import type { WidgetImports } from ".";
import type {
inferOptionsFromDefinition,
WidgetOptionsRecord,
@@ -11,37 +11,37 @@ import type {
import type { IntegrationSelectOption } from "./widget-integration-select";
export const createWidgetDefinition = <
TSort extends WidgetSort,
TDefinition extends Definition,
TKind extends WidgetKind,
TDefinition extends WidgetDefinition,
>(
sort: TSort,
kind: TKind,
definition: TDefinition,
) => ({
withDynamicImport: (
componentLoader: () => LoaderComponent<WidgetComponentProps<TSort>>,
componentLoader: () => LoaderComponent<WidgetComponentProps<TKind>>,
) => ({
definition: {
sort,
kind,
...definition,
},
componentLoader,
}),
});
interface Definition {
export interface WidgetDefinition {
icon: (props: TablerIconsProps) => JSX.Element;
supportedIntegrations?: IntegrationKind[];
options: WidgetOptionsRecord;
}
export interface WidgetComponentProps<TSort extends WidgetSort> {
options: inferOptionsFromDefinition<WidgetOptionsRecordOf<TSort>>;
export interface WidgetComponentProps<TKind extends WidgetKind> {
options: inferOptionsFromDefinition<WidgetOptionsRecordOf<TKind>>;
integrations: inferIntegrationsFromDefinition<
WidgetImports[TSort]["definition"]
WidgetImports[TKind]["definition"]
>;
}
type inferIntegrationsFromDefinition<TDefinition extends Definition> =
type inferIntegrationsFromDefinition<TDefinition extends WidgetDefinition> =
TDefinition extends {
supportedIntegrations: infer TSupportedIntegrations;
} // check if definition has supportedIntegrations
@@ -57,5 +57,5 @@ interface IntegrationSelectOptionFor<TIntegration extends IntegrationKind> {
kind: TIntegration[number];
}
export type WidgetOptionsRecordOf<TSort extends WidgetSort> =
WidgetImports[TSort]["definition"]["options"];
export type WidgetOptionsRecordOf<TKind extends WidgetKind> =
WidgetImports[TKind]["definition"]["options"];

View File

@@ -1,5 +1,5 @@
import type { WidgetSort } from ".";
import type { WidgetKind } from "@homarr/definitions";
export type WidgetImportRecord = {
[K in WidgetSort]: unknown;
[K in WidgetKind]: unknown;
};

View File

@@ -1,6 +1,8 @@
import type { ComponentType } from "react";
import dynamic from "next/dynamic";
import type { Loader } from "next/dynamic";
import type { WidgetKind } from "@homarr/definitions";
import { Loader as UiLoader } from "@homarr/ui";
import * as clock from "./clock";
@@ -12,21 +14,30 @@ export { reduceWidgetOptionsWithDefaultValues } from "./options";
export { WidgetEditModal } from "./modals/widget-edit-modal";
export const widgetSorts = ["clock", "weather"] as const;
export const widgetImports = {
clock,
weather,
} satisfies WidgetImportRecord;
export type WidgetSort = (typeof widgetSorts)[number];
export type WidgetImports = typeof widgetImports;
export type WidgetImportKey = keyof WidgetImports;
export const loadWidgetDynamic = <TSort extends WidgetSort>(sort: TSort) =>
dynamic<WidgetComponentProps<TSort>>(
widgetImports[sort].componentLoader as Loader<WidgetComponentProps<TSort>>,
const loadedComponents = new Map<
WidgetKind,
ComponentType<WidgetComponentProps<WidgetKind>>
>();
export const loadWidgetDynamic = <TKind extends WidgetKind>(kind: TKind) => {
const existingComponent = loadedComponents.get(kind);
if (existingComponent) return existingComponent;
const newlyLoadedComponent = dynamic<WidgetComponentProps<TKind>>(
widgetImports[kind].componentLoader as Loader<WidgetComponentProps<TKind>>,
{
loading: () => <UiLoader />,
},
);
loadedComponents.set(kind, newlyLoadedComponent as never);
return newlyLoadedComponent;
};

View File

@@ -1,46 +1,46 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import type { ManagedModal } from "mantine-modal-manager";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { Button, Group, Stack } from "@homarr/ui";
import type { WidgetSort } from "..";
import { widgetImports } from "..";
import { getInputForType } from "../_inputs";
import { FormProvider, useForm } from "../_inputs/form";
import type { WidgetOptionsRecordOf } from "../definition";
import type { WidgetOptionDefinition } from "../options";
import { WidgetIntegrationSelect } from "../widget-integration-select";
import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } from "../widget-integration-select";
export interface WidgetEditModalState {
options: Record<string, unknown>;
integrations: string[];
}
interface ModalProps<TSort extends WidgetSort> {
sort: TSort;
state: [WidgetEditModalState, Dispatch<SetStateAction<WidgetEditModalState>>];
definition: WidgetOptionsRecordOf<TSort>;
interface ModalProps<TSort extends WidgetKind> {
kind: TSort;
value: WidgetEditModalState;
onSuccessfulEdit: (value: WidgetEditModalState) => void;
integrationData: IntegrationSelectOption[];
integrationSupport: boolean;
}
export const WidgetEditModal: ManagedModal<ModalProps<WidgetSort>> = ({
export const WidgetEditModal: ManagedModal<ModalProps<WidgetKind>> = ({
actions,
innerProps,
}) => {
const t = useScopedI18n("widget.editModal");
const [value, setValue] = innerProps.state;
const t = useI18n();
const form = useForm({
initialValues: value,
initialValues: innerProps.value,
});
const { definition } = widgetImports[innerProps.kind];
return (
<form
onSubmit={form.onSubmit((v) => {
setValue(v);
innerProps.onSuccessfulEdit(v);
actions.closeModal();
})}
>
@@ -48,12 +48,12 @@ export const WidgetEditModal: ManagedModal<ModalProps<WidgetSort>> = ({
<Stack>
{innerProps.integrationSupport && (
<WidgetIntegrationSelect
label={t("integrations.label")}
label={t("item.edit.field.integrations.label")}
data={innerProps.integrationData}
{...form.getInputProps("integrations")}
/>
)}
{Object.entries(innerProps.definition).map(
{Object.entries(definition.options).map(
([key, value]: [string, WidgetOptionDefinition]) => {
const Input = getInputForType(value.type);
@@ -64,7 +64,7 @@ export const WidgetEditModal: ManagedModal<ModalProps<WidgetSort>> = ({
return (
<Input
key={key}
sort={innerProps.sort}
kind={innerProps.kind}
property={key}
options={value as never}
/>
@@ -73,10 +73,10 @@ export const WidgetEditModal: ManagedModal<ModalProps<WidgetSort>> = ({
)}
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
Close
{t("common.action.cancel")}
</Button>
<Button type="submit" color="teal">
Save
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>

View File

@@ -1,6 +1,9 @@
import { objectEntries } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import type { z } from "@homarr/validation";
import { widgetImports } from ".";
interface CommonInput<TType> {
defaultValue?: TType;
withDescription?: boolean;
@@ -143,13 +146,16 @@ export const opt = {
};
export const reduceWidgetOptionsWithDefaultValues = (
optionsDefinition: Record<string, WidgetOptionDefinition>,
kind: WidgetKind,
currentValue: Record<string, unknown> = {},
) =>
objectEntries(optionsDefinition).reduce(
) => {
const definition = widgetImports[kind].definition;
const options = definition.options as Record<string, WidgetOptionDefinition>;
return objectEntries(options).reduce(
(prev, [key, value]) => ({
...prev,
[key]: currentValue[key] ?? value.defaultValue,
}),
{} as Record<string, unknown>,
);
};