Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: open apps in new tab #1367

Open
wants to merge 4 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/nextjs/src/app/[locale]/manage/apps/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text,
import { IconApps, IconPencil } from "@tabler/icons-react";

import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { api } from "@homarr/api/server";
import { parseAppHrefWithVariablesServer } from "@homarr/common/server";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
Expand Down Expand Up @@ -45,6 +46,7 @@ interface AppCardProps {

const AppCard = async ({ app }: AppCardProps) => {
const t = await getScopedI18n("app");
const [openAppsInNewTab] = clientApi.user.getOpenAppsInNewTabOrDefault.useSuspenseQuery();

return (
<Card>
Expand All @@ -70,7 +72,13 @@ const AppCard = async ({ app }: AppCardProps) => {
</Text>
)}
{app.href && (
<Anchor href={parseAppHrefWithVariablesServer(app.href)} lineClamp={1} size="sm" w="min-content">
<Anchor
href={parseAppHrefWithVariablesServer(app.href)}
target={openAppsInNewTab ? "_blank" : "_self"}
lineClamp={1}
size="sm"
w="min-content"
>
{parseAppHrefWithVariablesServer(app.href)}
</Anchor>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";

import { Button, Group, Stack, Switch } from "@mantine/core";

import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";

interface OpenAppsInNewTabProps {
user: RouterOutputs["user"]["getById"];
}

export const OpenAppsInNewTab = ({ user }: OpenAppsInNewTabProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeOpenAppsInNewTab.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
openAppsInNewTab: variables.openAppsInNewTab,
});
showSuccessNotification({
message: t("user.action.changeOpenAppsInNewTab.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeOpenAppsInNewTab.notification.error.message"),
});
},
});
const form = useZodForm(validation.user.openAppsInNewTab, {
initialValues: {
openAppsInNewTab: user.openAppsInNewTab,
},
});

const handleSubmit = (values: FormType) => {
mutate({
id: user.id,
...values,
});
};

return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Switch {...form.getInputProps("openAppsInNewTab")} label={t("user.field.openAppsInNewTab.label")} />

<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};

type FormType = z.infer<typeof validation.user.openAppsInNewTab>;
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { canAccessUserEditPage } from "../access";
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
import { OpenAppsInNewTab } from "./_components/_open-apps-in-new-tab";
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
Expand Down Expand Up @@ -100,6 +101,11 @@ export default async function EditUserPage({ params }: Props) {
<FirstDayOfWeek user={user} />
</Stack>

<Stack mb="lg">
<Title order={2}>{tGeneral("item.openAppsInNewTab")}</Title>
<OpenAppsInNewTab user={user} />
</Stack>

<Stack mb="lg">
<Title order={2}>{tGeneral("item.accessibility")}</Title>
<PingIconsEnabled user={user} />
Expand Down
34 changes: 34 additions & 0 deletions packages/api/src/router/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ export const userRouter = createTRPCRouter({
provider: true,
homeBoardId: true,
firstDayOfWeek: true,
openAppsInNewTab: true,
pingIconsEnabled: true,
},
where: eq(users.id, input.userId),
Expand Down Expand Up @@ -457,6 +458,39 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, ctx.session.user.id));
}),
getOpenAppsInNewTabOrDefault: publicProcedure.query(async ({ ctx }) => {
if (!ctx.session?.user) {
return false;
}

const user = await ctx.db.query.users.findFirst({
columns: {
id: true,
openAppsInNewTab: true,
},
where: eq(users.id, ctx.session.user.id),
});

return user?.openAppsInNewTab ?? false;
}),
changeOpenAppsInNewTab: protectedProcedure
.input(validation.user.openAppsInNewTab.and(validation.common.byId))
.mutation(async ({ input, ctx }) => {
// Only admins can change other users open apps in new tab
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}

await ctx.db
.update(users)
.set({
openAppsInNewTab: input.openAppsInNewTab,
})
.where(eq(users.id, ctx.session.user.id));
}),
});

const createUserAsync = async (db: Database, input: z.infer<typeof validation.user.create>) => {
Expand Down
1 change: 1 addition & 0 deletions packages/db/schema/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const users = mysqlTable("user", {
colorScheme: varchar("colorScheme", { length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: tinyint("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean("pingIconsEnabled").default(false).notNull(),
openAppsInNewTab: boolean("openAppsInNewTab").default(false).notNull(),
});

export const accounts = mysqlTable(
Expand Down
3 changes: 2 additions & 1 deletion packages/db/schema/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { relations, sql } from "drizzle-orm";
import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
import { blob, index, int, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";

import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
import type {
BackgroundImageAttachment,
BackgroundImageRepeat,
Expand All @@ -21,6 +20,7 @@ import type {
SupportedAuthProvider,
WidgetKind,
} from "@homarr/definitions";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";

export const apiKeys = sqliteTable("apiKey", {
id: text("id").notNull().primaryKey(),
Expand Down Expand Up @@ -48,6 +48,7 @@ export const users = sqliteTable("user", {
colorScheme: text("colorScheme").$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: int("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int("pingIconsEnabled", { mode: "boolean" }).default(false).notNull(),
openAppsInNewTab: int("openAppsInNewTab", { mode: "boolean" }).default(false).notNull(),
});

export const accounts = sqliteTable(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,40 +13,46 @@ import { interaction } from "../../lib/interaction";
type App = { id: string; name: string; iconUrl: string; href: string | null };

const appChildrenOptions = createChildrenOptions<App>({
useActions: () => [
{
key: "open",
Component: () => {
const t = useI18n();
useActions: () => {
const [openAppsInNewTab] = clientApi.user.getOpenAppsInNewTabOrDefault.useSuspenseQuery();
return [
{
key: "open",
Component: () => {
const t = useI18n();

return (
<Group mx="md" my="sm">
<IconExternalLink stroke={1.5} />
<Text>{t("search.mode.appIntegrationBoard.group.app.children.action.open.label")}</Text>
</Group>
);
},
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
useInteraction: interaction.link((option) => ({ href: option.href! })),
hide(option) {
return !option.href;
return (
<Group mx="md" my="sm">
<IconExternalLink stroke={1.5} />
<Text>{t("search.mode.appIntegrationBoard.group.app.children.action.open.label")}</Text>
</Group>
);
},

useInteraction: interaction.link((option) => ({
href: option.href ?? "",
newTab: openAppsInNewTab,
})),
hide(option) {
return !option.href;
},
},
},
{
key: "edit",
Component: () => {
const t = useI18n();
{
key: "edit",
Component: () => {
const t = useI18n();

return (
<Group mx="md" my="sm">
<IconEye stroke={1.5} />
<Text>{t("search.mode.appIntegrationBoard.group.app.children.action.edit.label")}</Text>
</Group>
);
return (
<Group mx="md" my="sm">
<IconEye stroke={1.5} />
<Text>{t("search.mode.appIntegrationBoard.group.app.children.action.edit.label")}</Text>
</Group>
);
},
useInteraction: interaction.link(({ id }) => ({ href: `/manage/apps/edit/${id}` })),
},
useInteraction: interaction.link(({ id }) => ({ href: `/manage/apps/edit/${id}` })),
},
],
];
},
DetailComponent: ({ options }) => {
const t = useI18n();

Expand Down
Loading
Loading