Team

AIプロダクトのワークスペース設定向けの、チームメンバーと招待です。入力中の招待をプレビューするシートメーター、貼り付けたリストを確認済みのチップに変える招待ボックス、ロール、検索、フィルター、並べ替えを備えたメンバー、再送のクールダウンとコピーできるリンクを持つ保留中の招待、入力による確認つきのオーナー移譲、取り消しできる削除を備えています。

ワークスペースを持つAIプロダクトは、結局は同じページに行き着きます。チームにいるのは誰か、各人は何ができるか、誰が招待されているか、シートがあといくつ残っているか、です。Teamは、そのページを、設定セクションに組み込める1つのコンポーネントにしたものです。メンバー、招待、シート数を読み取り、誰かが操作すると自分のハンドラーを呼び出します。データを自分で変更することはないので、表示される内容は常にサーバーの状態と一致します。

招待ボックスは、人々が実際に持っている形のアドレスを受け付けます。入力してカンマ、スペース、Enterを押すか、スプレッドシートの1列、メールのTo:行、Ana Lima <[email protected]> のような名前つきのリストを貼り付けてください。各アドレスはチップになります。重複は取り除かれ、貼り付け時にはアドレスではない単語はスキップされ、無効なアドレス、既存のメンバー、すでに招待を受けている人のチップには理由が示され、それらをまとめて削除するボタンも表示されます。ロールを選び、必要に応じてメモを追加して送信します。

シートメーターは、メンバー、保留中の招待、入力中のチップを3つの層で表示するため、送信する前に招待のコストがわかります。期限切れの招待はシートを占有しません。招待が上限を超える場合、送信はブロックされ、何件削除すべきかが示され、上限に達するとメーターはAdd seatsの行動喚起に変わります。

各メンバーの行には、その人のロールがメニューとして表示され、各ロールでできることが示されます。ガードレールは組み込み済みです。唯一のオーナーは、オーナーシップを移譲するまで自分を降格したり退出したりできず、メニューはオプションをこっそり隠す代わりにその旨を伝えます。管理者はオーナーに手を加えられません。誰かをオーナーにしたり、自分のロールを下げたりするときは、先に確認を求めます。オーナーシップの移譲では、新しいオーナーのメールアドレスの入力を求めるため、別のAnaに鍵が渡ることはありません。

保留中の招待には、送信された時刻、期限切れが近いこと、期限が切れたことが表示されます。Resendはリンクを更新し、最後の送信から数えたクールダウンを開始するため、再読み込みしてもカウントダウンは維持されます。Copy invite linkは実際のリンクをクリップボードにコピーします。メンバーの削除は一度確認し、その後Undoを提示し、フォーカスはページの先頭に戻らず次の人に移ります。

  1. Proレジストリをcomponents.jsonに追加する

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. トークンを追加する

    アカウントページでトークンを作成し、.env.local に HEXTAUI_PRO_TOKEN として設定してください。

  3. ブロックを追加する

    pnpm dlx shadcn@latest add @hextaui-pro/team

APIに接続する

TeamSettingsは制御コンポーネントです。members、invites、seatsを渡し、各ハンドラーで更新してください。サーバーが拒否したアドレスを、各チップに理由を付けて保持するには、onInviteから { email: message } を返します。

"use client"

import * as React from "react"

import { SettingsSection, SettingsShell } from "../settings/settings"
import { TeamSettings, type TeamInvite, type TeamMember } from "@/components/blocks/team/team"

type Team = { seats: number; members: TeamMember[]; invites: TeamInvite[] }

async function call<Result = void>(path: string, init?: RequestInit) {
  const response = await fetch(path, {
    ...init,
    headers: { "content-type": "application/json" },
  })
  if (!response.ok) throw new Error((await response.text()) || "Try again in a moment.")
  return (response.status === 204 ? undefined : await response.json()) as Result
}

export function TeamPage({ team: initial, userId }: { team: Team; userId: string }) {
  const [team, setTeam] = React.useState(initial)
  const reload = async () => setTeam(await call<Team>("/api/team"))

  return (
    <SettingsShell sections={[{ id: "team", label: "Members" }]} className="h-svh">
      <SettingsSection id="team">
        <TeamSettings
          members={team.members}
          invites={team.invites}
          seats={team.seats}
          currentUserId={userId}
          onInvite={async (request) => {
            const { errors } = await call<{ errors: Record<string, string> }>("/api/team/invites", {
              method: "POST",
              body: JSON.stringify(request),
            })
            await reload()
            return errors
          }}
          onRoleChange={async (id, role) => {
            await call(`/api/team/members/${id}`, { method: "PATCH", body: JSON.stringify({ role }) })
            await reload()
          }}
          onRemove={async (id) => {
            await call(`/api/team/members/${id}`, { method: "DELETE" })
            await reload()
          }}
          onResend={async (id) => {
            await call(`/api/team/invites/${id}/resend`, { method: "POST" })
            await reload()
          }}
          onRevoke={async (id) => {
            await call(`/api/team/invites/${id}`, { method: "DELETE" })
            await reload()
          }}
          upgradeHref="/billing?add=seats"
        />
      </SettingsSection>
    </SettingsShell>
  )
}

許可したものだけ

各アクションは、ハンドラーが設定されていて、かつ実行を許可された人にだけ表示されます。onInviteがなければ招待ボックスはなく、メンバーと閲覧者にはメニューのないリストが表示されます。

"use client"

import { TeamSettings, type TeamMember } from "@/components/blocks/team/team"

const members: TeamMember[] = [
  { id: "u1", name: "Mia Chen", email: "[email protected]", role: "owner", lastActiveAt: Date.now() },
  { id: "u2", name: "Jonas Weber", email: "[email protected]", role: "admin", lastActiveAt: null },
]

export function ReadOnlyTeam({ userId }: { userId: string }) {
  return <TeamSettings members={members} currentUserId={userId} seats={5} />
}

export function OwnerTeam({ userId }: { userId: string }) {
  return (
    <TeamSettings
      members={members}
      currentUserId={userId}
      seats={5}
      onRoleChange={async () => {}}
      onRemove={async () => {}}
      onTransferOwnership={async (id) => {
        await fetch(`/api/team/owner`, { method: "PUT", body: JSON.stringify({ id }) })
      }}
    />
  )
}

削除を元に戻す

onRestoreを渡すと、削除後のトーストにUndoボタンが付き、削除されたメンバーが渡されるので、元に戻せます。

"use client"

import * as React from "react"

import { TeamSettings, type TeamMember } from "@/components/blocks/team/team"

export function TeamWithUndo({ initial, userId }: { initial: TeamMember[]; userId: string }) {
  const [members, setMembers] = React.useState(initial)

  return (
    <TeamSettings
      members={members}
      currentUserId={userId}
      onRemove={async (id) => {
        await fetch(`/api/team/members/${id}`, { method: "DELETE" })
        setMembers((current) => current.filter((member) => member.id !== id))
      }}
      onRestore={async (member) => {
        await fetch(`/api/team/members`, { method: "POST", body: JSON.stringify(member) })
        setMembers((current) => [...current, member])
      }}
    />
  )
}

構造

外側から内側へ組み合わせるパーツ。

パーツ説明
TeamSettingsページ全体。シート、招待ボックス、保留中の招待、メンバー、任意のドメイン自動参加の行。SettingsSectionの内側に置きます。
parseEmailList招待ボックスの背後にあるパーサー。貼り付けまたは入力されたテキストを小文字のアドレスに分割し、Name <email> やmailto: の形式を展開し、貼り付け時にはアドレスではない単語をスキップします。
teamRoles4つのロールとそのラベルおよび説明。アクセス権が多い順に並びます。
プロパティ型デフォルト
members{ id, name, email, role, avatarUrl?, lastActiveAt?, joinedAt? }。lastActiveAtがnullの場合、Not yet activeが表示されます。
TeamMember[]–
invites{ id, email, role, sentAt, expiresAt, link? }。時刻にはDate、ISO文字列、ミリ秒を指定できます。linkを指定すると、Copy invite linkが有効になります。
TeamInvite[][]
currentUserIdサインイン中のメンバー。そのロールによって、表示されるアクションが決まります。
string–
seatsプランのシート数。メンバーと期限切れでない招待が、それぞれ1つずつ使用します。シート数を無制限にするには省略します。
number–
domain確認済みの会社のメールアドレスを持つ人が、招待なしで参加できるようにする行を表示します。
{ name, verified, autoJoin, role? }–
resendCooldown招待を送信してから、再び送信できるようになるまでの秒数。
number60
pageSizeShow moreの前に表示するメンバーの数。
number50
onInvite招待ボックスを追加します。サーバーが拒否したアドレスについて、メールアドレスごとにメッセージを返します。それらはメッセージ付きのチップとして残ります。エラーを投げると、すべてのチップが保持され、ボタンにエラーが表示されます。
({ emails, role, note }) => void | { [email]: message } | Promise–
onRoleChangeロールメニューを追加します。resolveされるまで行にはスピナーが表示され、エラーを投げるとトーストが表示されます。
(memberId, role) => void | Promise–
onRemoveRemove from teamを追加し、自分の行にはLeave teamを追加します。エラーを投げると、メッセージとともにダイアログが開いたままになります。
(memberId) => void | Promise–
onRestore削除後のトーストに、削除前の状態のメンバーを渡すUndoを追加します。
(member) => void | Promise–
onTransferOwnershipオーナー向けに、新しいオーナーのメールアドレスの入力で確認するTransfer ownershipを追加します。
(memberId) => void | Promise–
onResendResendを追加します。クールダウンを開始してリンクを更新するために、sentAtとexpiresAtを更新してください。
(inviteId) => void | Promise–
onRevokeRevoke inviteを追加します。
(inviteId) => void | Promise–
onAutoJoinChangeドメインのスイッチを有効にします。エラーを投げると、揺れてその場に留まります。
(enabled) => void | Promise–
onUpgradeAdd seatsボタンを追加します。すべてのシートが埋まると、これが主要なアクションになります。
() => void–
upgradeHrefAdd seatsをリンクにします。
string–
classNameラッパーのクラス。グループの列です。
string–

parseEmailList

{ emails, skipped } を返します。

プロパティ型デフォルト
textカンマ、セミコロン、スペース、タブ、改行で区切られたアドレスを含む任意のもの。
string–
options.paste少なくとも1つのアドレスがある場合は、@を含まない単語をスキップし、skippedに数えます。
booleanfalse
キーアクション
Enter招待ボックスで、入力した内容をチップにします。空のボックスでもう一度押すと送信します。
,入力した内容をチップにします。スペースとセミコロンも同様です。
Backspace空の招待ボックスで、最後のチップに移動します。そこでBackspaceまたはDeleteを押すと削除され、1つ前のチップに移動します。
⌘Enterアドレスボックスまたはメモから招待を送信します。WindowsとLinuxではCtrl+Enterです。
Escapeメンバーの検索をクリアするか、開いているメニューやダイアログを閉じます。
  • メンバーの行は、その人にちなんだ名前を持つリスト項目です。ロールメニューには「Admin, change role for Ana Lima」のような名前が、その他ボタンには「More actions for Ana Lima」のような名前が付きます。
  • 送信できないチップには、名前に理由が含まれ、ボックスの下の問題の一覧は説明として入力に関連付けられます。
  • アドレスの追加、送信、ロールの変更、削除は、1つのライブリージョンでpoliteに読み上げられます。ダイアログ内のエラーはalertとして読み上げられます。
  • 無効なアクションは自身で理由を説明します。ロールメニューとその他のメニューは、唯一のオーナーがロールを変更したり退出したりできない理由を示し、Resendはクールダウン中もフォーカス可能で、名前に残り時間が含まれます。
  • 削除後は、フォーカスが次のメンバーのアクション、リストの末尾では前のメンバーのアクションに移ります。招待の取り消しも、招待に対して同じ動作をします。
  • シートメーターはmeterで、ラベルと件数がテキストとして付きます。モーション軽減時は、スライドせずに変化します。

使用技術

Team を構成する無料のHextaUIコンポーネントです。それぞれ単独でインストールできます。

コード

3 個のファイルを components/blocks/team に追加しました。