From b38b55a805fa66858493fa251bd6dcccdea3c5e3 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Fri, 25 Apr 2025 15:35:41 +0200 Subject: [PATCH] frontend: allow setting custom names to sessions --- frontend/locales/en.json | 5 + .../CompatSessionDetail.test.tsx | 13 +- .../SessionDetail/CompatSessionDetail.tsx | 29 ++++- .../SessionDetail/EditSessionName.tsx | 100 +++++++++++++++ .../OAuth2SessionDetail.test.tsx | 9 +- .../SessionDetail/OAuth2SessionDetail.tsx | 26 ++++ .../CompatSessionDetail.test.tsx.snap | 121 ++++++++++++++---- .../OAuth2SessionDetail.test.tsx.snap | 64 ++++++++- frontend/src/gql/gql.ts | 12 ++ frontend/src/gql/graphql.ts | 78 +++++++++++ 10 files changed, 427 insertions(+), 30 deletions(-) create mode 100644 frontend/src/components/SessionDetail/EditSessionName.tsx diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 7f0343e85..405a4bacf 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -258,6 +258,11 @@ "last_active_label": "Last Active", "name_for_platform": "{{name}} for {{platform}}", "scopes_label": "Scopes", + "set_device_name": { + "help": "Set a name that will help you identify this device.", + "label": "Device name", + "title": "Edit device name" + }, "signed_in_label": "Signed in", "title": "Device details", "unknown_browser": "Unknown browser", diff --git a/frontend/src/components/SessionDetail/CompatSessionDetail.test.tsx b/frontend/src/components/SessionDetail/CompatSessionDetail.test.tsx index 30fb72418..9645c9e71 100644 --- a/frontend/src/components/SessionDetail/CompatSessionDetail.test.tsx +++ b/frontend/src/components/SessionDetail/CompatSessionDetail.test.tsx @@ -6,6 +6,7 @@ // @vitest-environment happy-dom +import { TooltipProvider } from "@vector-im/compound-web"; import { beforeAll, describe, expect, it } from "vitest"; import { makeFragmentData } from "../../gql"; import { mockLocale } from "../../test-utils/mockLocale"; @@ -33,7 +34,9 @@ describe("", () => { const data = makeFragmentData({ ...baseSession }, FRAGMENT); const { container, getByText, queryByText } = render( - , + + + , ); expect(container).toMatchSnapshot(); @@ -51,7 +54,9 @@ describe("", () => { ); const { container, getByText, queryByText } = render( - , + + + , ); expect(container).toMatchSnapshot(); @@ -69,7 +74,9 @@ describe("", () => { ); const { container, getByText, queryByText } = render( - , + + + , ); expect(container).toMatchSnapshot(); diff --git a/frontend/src/components/SessionDetail/CompatSessionDetail.tsx b/frontend/src/components/SessionDetail/CompatSessionDetail.tsx index 47ab93d8d..17101a07b 100644 --- a/frontend/src/components/SessionDetail/CompatSessionDetail.tsx +++ b/frontend/src/components/SessionDetail/CompatSessionDetail.tsx @@ -4,17 +4,28 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { VisualList } from "@vector-im/compound-web"; import { parseISO } from "date-fns"; import { useTranslation } from "react-i18next"; import { type FragmentType, graphql, useFragment } from "../../gql"; +import { graphqlRequest } from "../../graphql"; import simplifyUrl from "../../utils/simplifyUrl"; import DateTime from "../DateTime"; import EndCompatSessionButton from "../Session/EndCompatSessionButton"; import LastActive from "../Session/LastActive"; +import EditSessionName from "./EditSessionName"; import SessionHeader from "./SessionHeader"; import * as Info from "./SessionInfo"; +const SET_SESSION_NAME_MUTATION = graphql(/* GraphQL */ ` + mutation SetCompatSessionName($sessionId: ID!, $displayName: String!) { + setCompatSessionName(input: { compatSessionId: $sessionId, humanName: $displayName }) { + status + } + } +`); + export const FRAGMENT = graphql(/* GraphQL */ ` fragment CompatSession_detail on CompatSession { id @@ -47,6 +58,19 @@ type Props = { const CompatSessionDetail: React.FC = ({ session }) => { const data = useFragment(FRAGMENT, session); const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const setDisplayName = useMutation({ + mutationFn: (displayName: string) => + graphqlRequest({ + query: SET_SESSION_NAME_MUTATION, + variables: { sessionId: data.id, displayName }, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["sessionDetail", data.id] }); + queryClient.invalidateQueries({ queryKey: ["sessionsOverview"] }); + }, + }); const deviceName = data.userAgent?.model ?? @@ -67,7 +91,10 @@ const CompatSessionDetail: React.FC = ({ session }) => { return (
- {sessionName} + + {sessionName} + + {t("frontend.session.title")} diff --git a/frontend/src/components/SessionDetail/EditSessionName.tsx b/frontend/src/components/SessionDetail/EditSessionName.tsx new file mode 100644 index 000000000..4a57c34e8 --- /dev/null +++ b/frontend/src/components/SessionDetail/EditSessionName.tsx @@ -0,0 +1,100 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +import IconEdit from "@vector-im/compound-design-tokens/assets/web/icons/edit"; +import { Button, Form, IconButton, Tooltip } from "@vector-im/compound-web"; +import { + type ComponentPropsWithoutRef, + forwardRef, + useRef, + useState, +} from "react"; +import * as Dialog from "../Dialog"; +import LoadingSpinner from "../LoadingSpinner"; + +import type { UseMutationResult } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +// This needs to be its own component because else props and refs aren't passed properly in the trigger +const EditButton = forwardRef< + HTMLButtonElement, + { label: string } & ComponentPropsWithoutRef<"button"> +>(({ label, ...props }, ref) => ( + + + + + +)); + +type Props = { + mutation: UseMutationResult; + deviceName: string; +}; + +const EditSessionName: React.FC = ({ mutation, deviceName }) => { + const { t } = useTranslation(); + const fieldRef = useRef(null); + const [open, setOpen] = useState(false); + + const onSubmit = async ( + event: React.FormEvent, + ): Promise => { + event.preventDefault(); + + const form = event.currentTarget; + const formData = new FormData(form); + const displayName = formData.get("name") as string; + await mutation.mutateAsync(displayName); + setOpen(false); + }; + return ( + } + open={open} + onOpenChange={(open) => { + // Reset the form when the dialog is opened or closed + fieldRef.current?.form?.reset(); + setOpen(open); + }} + > + {t("frontend.session.set_device_name.title")} + + + + {t("frontend.session.set_device_name.label")} + + + + + {t("frontend.session.set_device_name.help")} + + + + + {mutation.isPending && } + {t("action.save")} + + + + + + + + ); +}; + +export default EditSessionName; diff --git a/frontend/src/components/SessionDetail/OAuth2SessionDetail.test.tsx b/frontend/src/components/SessionDetail/OAuth2SessionDetail.test.tsx index 7f33da2bb..8aa60c6bd 100644 --- a/frontend/src/components/SessionDetail/OAuth2SessionDetail.test.tsx +++ b/frontend/src/components/SessionDetail/OAuth2SessionDetail.test.tsx @@ -11,6 +11,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import { makeFragmentData } from "../../gql"; import { mockLocale } from "../../test-utils/mockLocale"; +import { TooltipProvider } from "@vector-im/compound-web"; import render from "../../test-utils/render"; import OAuth2SessionDetail, { FRAGMENT } from "./OAuth2SessionDetail"; @@ -39,7 +40,9 @@ describe("", () => { const data = makeFragmentData(baseSession, FRAGMENT); const { asFragment, getByText, queryByText } = render( - , + + + , ); expect(asFragment()).toMatchSnapshot(); @@ -57,7 +60,9 @@ describe("", () => { ); const { asFragment, getByText, queryByText } = render( - , + + + , ); expect(asFragment()).toMatchSnapshot(); diff --git a/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx b/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx index 7f2f9a94c..2dc850d43 100644 --- a/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx +++ b/frontend/src/components/SessionDetail/OAuth2SessionDetail.tsx @@ -4,17 +4,28 @@ // SPDX-License-Identifier: AGPL-3.0-only // Please see LICENSE in the repository root for full details. +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { parseISO } from "date-fns"; import { useTranslation } from "react-i18next"; import { type FragmentType, graphql, useFragment } from "../../gql"; +import { graphqlRequest } from "../../graphql"; import { getDeviceIdFromScope } from "../../utils/deviceIdFromScope"; import DateTime from "../DateTime"; import ClientAvatar from "../Session/ClientAvatar"; import EndOAuth2SessionButton from "../Session/EndOAuth2SessionButton"; import LastActive from "../Session/LastActive"; +import EditSessionName from "./EditSessionName"; import SessionHeader from "./SessionHeader"; import * as Info from "./SessionInfo"; +const SET_SESSION_NAME_MUTATION = graphql(/* GraphQL */ ` + mutation SetOAuth2SessionName($sessionId: ID!, $displayName: String!) { + setOauth2SessionName(input: { oauth2SessionId: $sessionId, humanName: $displayName }) { + status + } + } +`); + export const FRAGMENT = graphql(/* GraphQL */ ` fragment OAuth2Session_detail on Oauth2Session { id @@ -50,6 +61,19 @@ type Props = { const OAuth2SessionDetail: React.FC = ({ session }) => { const data = useFragment(FRAGMENT, session); const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const setDisplayName = useMutation({ + mutationFn: (displayName: string) => + graphqlRequest({ + query: SET_SESSION_NAME_MUTATION, + variables: { sessionId: data.id, displayName }, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["sessionDetail", data.id] }); + queryClient.invalidateQueries({ queryKey: ["sessionsOverview"] }); + }, + }); const deviceId = getDeviceIdFromScope(data.scope); const clientName = data.client.clientName || data.client.clientId; @@ -70,7 +94,9 @@ const OAuth2SessionDetail: React.FC = ({ session }) => {
{clientName}: {deviceName} + + {t("frontend.session.title")} diff --git a/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap b/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap index 2fe6298f3..fbfd98192 100644 --- a/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap +++ b/frontend/src/components/SessionDetail/__snapshots__/CompatSessionDetail.test.tsx.snap @@ -27,9 +27,38 @@ exports[` > renders a compatability session details 1`] = `

- element.io - : - Unknown device + element.io: Unknown device +

> renders a compatability session details 1`] = `
> renders a compatability session without an ssoL Unknown device

-
  • -
    - Uri -
    -

    -

  • > renders a finished session details 1`] = ` class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112" > Element: Unknown device +
    > renders session details 1`] = ` class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112" > Element: Unknown device +
    > renders session details 1`] = `