feat: Add apps crud (#174)

* wip: add apps crud

* wip: add edit for apps

* feat: add apps crud

* fix: color of icon for no app results wrong

* ci: fix lint issues

* test: add unit tests for app crud

* ci: fix format issue

* fix: missing rename in edit form

* fix: missing callback deepsource issues
This commit is contained in:
Meier Lukas
2024-03-04 22:13:40 +01:00
committed by GitHub
parent 70f34efd53
commit 8d5984c58a
17 changed files with 1501 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,71 @@
import { TRPCError } from "@trpc/server";
import { asc, createId, eq } from "@homarr/db";
import { apps } from "@homarr/db/schema/sqlite";
import { validation } from "@homarr/validation";
import { createTRPCRouter, publicProcedure } from "../trpc";
export const appRouter = createTRPCRouter({
all: publicProcedure.query(async ({ ctx }) => {
return await ctx.db.query.apps.findMany({
orderBy: asc(apps.name),
});
}),
byId: publicProcedure
.input(validation.app.byId)
.query(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
});
if (!app) {
throw new TRPCError({
code: "NOT_FOUND",
message: "App not found",
});
}
return app;
}),
create: publicProcedure
.input(validation.app.manage)
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(apps).values({
id: createId(),
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
href: input.href,
});
}),
update: publicProcedure
.input(validation.app.edit)
.mutation(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
});
if (!app) {
throw new TRPCError({
code: "NOT_FOUND",
message: "App not found",
});
}
await ctx.db
.update(apps)
.set({
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
href: input.href,
})
.where(eq(apps.id, input.id));
}),
delete: publicProcedure
.input(validation.app.byId)
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(apps).where(eq(apps.id, input.id));
}),
});

View File

@@ -0,0 +1,209 @@
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/db";
import { apps } from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import { appRouter } from "../app";
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
describe("all should return all apps", () => {
test("should return all apps", async () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
session: null,
});
await db.insert(apps).values([
{
id: "2",
name: "Mantine",
description: "React components and hooks library",
iconUrl: "https://mantine.dev/favicon.svg",
href: "https://mantine.dev",
},
{
id: "1",
name: "Tabler Icons",
iconUrl: "https://tabler.io/favicon.ico",
},
]);
const result = await caller.all();
expect(result.length).toBe(2);
expect(result[0]!.id).toBe("2");
expect(result[1]!.id).toBe("1");
expect(result[0]!.href).toBeDefined();
expect(result[0]!.description).toBeDefined();
expect(result[1]!.href).toBeNull();
expect(result[1]!.description).toBeNull();
});
});
describe("byId should return an app by id", () => {
test("should return an app by id", async () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
session: null,
});
await db.insert(apps).values([
{
id: "2",
name: "Mantine",
description: "React components and hooks library",
iconUrl: "https://mantine.dev/favicon.svg",
href: "https://mantine.dev",
},
{
id: "1",
name: "Tabler Icons",
iconUrl: "https://tabler.io/favicon.ico",
},
]);
const result = await caller.byId({ id: "2" });
expect(result.name).toBe("Mantine");
});
test("should throw an error if the app does not exist", async () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
session: null,
});
const act = async () => await caller.byId({ id: "2" });
await expect(act()).rejects.toThrow("App not found");
});
});
describe("create should create a new app with all arguments", () => {
test("should create a new app", async () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
session: null,
});
const input = {
name: "Mantine",
description: "React components and hooks library",
iconUrl: "https://mantine.dev/favicon.svg",
href: "https://mantine.dev",
};
await caller.create(input);
const dbApp = await db.query.apps.findFirst();
expect(dbApp).toBeDefined();
expect(dbApp!.name).toBe(input.name);
expect(dbApp!.description).toBe(input.description);
expect(dbApp!.iconUrl).toBe(input.iconUrl);
expect(dbApp!.href).toBe(input.href);
});
test("should create a new app only with required arguments", async () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
session: null,
});
const input = {
name: "Mantine",
description: null,
iconUrl: "https://mantine.dev/favicon.svg",
href: null,
};
await caller.create(input);
const dbApp = await db.query.apps.findFirst();
expect(dbApp).toBeDefined();
expect(dbApp!.name).toBe(input.name);
expect(dbApp!.description).toBe(input.description);
expect(dbApp!.iconUrl).toBe(input.iconUrl);
expect(dbApp!.href).toBe(input.href);
});
});
describe("update should update an app", () => {
test("should update an app", async () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
session: null,
});
const appId = createId();
const toInsert = {
id: appId,
name: "Mantine",
iconUrl: "https://mantine.dev/favicon.svg",
};
await db.insert(apps).values(toInsert);
const input = {
id: appId,
name: "Mantine2",
description: "React components and hooks library",
iconUrl: "https://mantine.dev/favicon.svg2",
href: "https://mantine.dev",
};
await caller.update(input);
const dbApp = await db.query.apps.findFirst();
expect(dbApp).toBeDefined();
expect(dbApp!.name).toBe(input.name);
expect(dbApp!.description).toBe(input.description);
expect(dbApp!.iconUrl).toBe(input.iconUrl);
expect(dbApp!.href).toBe(input.href);
});
test("should throw an error if the app does not exist", async () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
session: null,
});
const act = async () =>
await caller.update({
id: createId(),
name: "Mantine",
iconUrl: "https://mantine.dev/favicon.svg",
description: null,
href: null,
});
await expect(act()).rejects.toThrow("App not found");
});
});
describe("delete should delete an app", () => {
test("should delete an app", async () => {
const db = createDb();
const caller = appRouter.createCaller({
db,
session: null,
});
const appId = createId();
await db.insert(apps).values({
id: appId,
name: "Mantine",
iconUrl: "https://mantine.dev/favicon.svg",
});
await caller.delete({ id: appId });
const dbApp = await db.query.apps.findFirst();
expect(dbApp).toBeUndefined();
});
});

View File

@@ -0,0 +1,7 @@
CREATE TABLE `app` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`description` text,
`icon_url` text NOT NULL,
`href` text
);

View File

@@ -0,0 +1,722 @@
{
"version": "5",
"dialect": "sqlite",
"id": "f7263224-116a-42ba-8fb1-4574cb637880",
"prevId": "7ba12cbf-d8eb-4469-b7c5-7f31acb7717d",
"tables": {
"account": {
"name": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"userId_idx": {
"name": "userId_idx",
"columns": ["userId"],
"isUnique": false
}
},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": ["provider", "providerAccountId"],
"name": "account_provider_providerAccountId_pk"
}
},
"uniqueConstraints": {}
},
"app": {
"name": "app",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon_url": {
"name": "icon_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"href": {
"name": "href",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"board": {
"name": "board",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_public": {
"name": "is_public",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"page_title": {
"name": "page_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"meta_title": {
"name": "meta_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"logo_image_url": {
"name": "logo_image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"favicon_image_url": {
"name": "favicon_image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"background_image_url": {
"name": "background_image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"background_image_attachment": {
"name": "background_image_attachment",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'fixed'"
},
"background_image_repeat": {
"name": "background_image_repeat",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'no-repeat'"
},
"background_image_size": {
"name": "background_image_size",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'cover'"
},
"primary_color": {
"name": "primary_color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'#fa5252'"
},
"secondary_color": {
"name": "secondary_color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'#fd7e14'"
},
"opacity": {
"name": "opacity",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 100
},
"custom_css": {
"name": "custom_css",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"column_count": {
"name": "column_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 10
}
},
"indexes": {
"board_name_unique": {
"name": "board_name_unique",
"columns": ["name"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"integration_item": {
"name": "integration_item",
"columns": {
"item_id": {
"name": "item_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"integration_id": {
"name": "integration_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"integration_item_item_id_item_id_fk": {
"name": "integration_item_item_id_item_id_fk",
"tableFrom": "integration_item",
"tableTo": "item",
"columnsFrom": ["item_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"integration_item_integration_id_integration_id_fk": {
"name": "integration_item_integration_id_integration_id_fk",
"tableFrom": "integration_item",
"tableTo": "integration",
"columnsFrom": ["integration_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"integration_item_item_id_integration_id_pk": {
"columns": ["integration_id", "item_id"],
"name": "integration_item_item_id_integration_id_pk"
}
},
"uniqueConstraints": {}
},
"integrationSecret": {
"name": "integrationSecret",
"columns": {
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"integration_id": {
"name": "integration_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"integration_secret__kind_idx": {
"name": "integration_secret__kind_idx",
"columns": ["kind"],
"isUnique": false
},
"integration_secret__updated_at_idx": {
"name": "integration_secret__updated_at_idx",
"columns": ["updated_at"],
"isUnique": false
}
},
"foreignKeys": {
"integrationSecret_integration_id_integration_id_fk": {
"name": "integrationSecret_integration_id_integration_id_fk",
"tableFrom": "integrationSecret",
"tableTo": "integration",
"columnsFrom": ["integration_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"integrationSecret_integration_id_kind_pk": {
"columns": ["integration_id", "kind"],
"name": "integrationSecret_integration_id_kind_pk"
}
},
"uniqueConstraints": {}
},
"integration": {
"name": "integration",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"integration__kind_idx": {
"name": "integration__kind_idx",
"columns": ["kind"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"item": {
"name": "item",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"section_id": {
"name": "section_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"x_offset": {
"name": "x_offset",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"y_offset": {
"name": "y_offset",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"options": {
"name": "options",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'{\"json\": {}}'"
}
},
"indexes": {},
"foreignKeys": {
"item_section_id_section_id_fk": {
"name": "item_section_id_section_id_fk",
"tableFrom": "item",
"tableTo": "section",
"columnsFrom": ["section_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"section": {
"name": "section",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"board_id": {
"name": "board_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"section_board_id_board_id_fk": {
"name": "section_board_id_board_id_fk",
"tableFrom": "section",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"session": {
"name": "session",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"user_id_idx": {
"name": "user_id_idx",
"columns": ["userId"],
"isUnique": false
}
},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"salt": {
"name": "salt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"verificationToken": {
"name": "verificationToken",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"columns": ["identifier", "token"],
"name": "verificationToken_identifier_token_pk"
}
},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1709409142712,
"tag": "0000_sloppy_bloodstorm",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1709585624230,
"tag": "0001_slim_swarm",
"breakpoints": true
}
]
}

View File

@@ -169,6 +169,14 @@ export const items = sqliteTable("item", {
options: text("options").default('{"json": {}}').notNull(), // empty superjson object
});
export const apps = sqliteTable("app", {
id: text("id").notNull().primaryKey(),
name: text("name").notNull(),
description: text("description"),
iconUrl: text("icon_url").notNull(),
href: text("href"),
});
export const integrationItems = sqliteTable(
"integration_item",
{

View File

@@ -28,6 +28,57 @@ export default {
create: "Create user",
},
},
app: {
page: {
list: {
title: "Apps",
noResults: {
title: "There aren't any apps.",
description: "Create your first app",
},
},
create: {
title: "New app",
notification: {
success: {
title: "Creation successful",
message: "The app was successfully created",
},
error: {
title: "Creation failed",
message: "The app could not be created",
},
},
},
edit: {
title: "Edit app",
notification: {
success: {
title: "Changes applied successfully",
message: "The app was successfully saved",
},
error: {
title: "Unable to apply changes",
message: "The app could not be saved",
},
},
},
delete: {
title: "Delete app",
message: "Are you sure you want to delete the app {name}?",
notification: {
success: {
title: "Deletion successful",
message: "The app was successfully deleted",
},
error: {
title: "Deletion failed",
message: "Unable to delete the app",
},
},
},
},
},
integration: {
page: {
list: {

View File

@@ -0,0 +1,18 @@
import { z } from "zod";
const manageAppSchema = z.object({
name: z.string().min(1).max(64),
description: z.string().max(512).nullable(),
iconUrl: z.string().min(1),
href: z.string().nullable(),
});
const editAppSchema = manageAppSchema.and(z.object({ id: z.string() }));
const byIdSchema = z.object({ id: z.string() });
export const appSchemas = {
manage: manageAppSchema,
edit: editAppSchema,
byId: byIdSchema,
};

View File

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