From bf9065d6e6440249a1c020e22f7df9918577c81b Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 11:36:26 -0400 Subject: [PATCH] Add `description` column to groups (#10511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add description column to groups - Add database migration to add description column to groups table - Update server-side Group model with description field and validation - Update group presenter to include description in API responses - Update API schemas to validate description field in create/update operations - Update client-side Group model with description field and search integration - Update unfurl types and presenter to include description for hover cards - Update HoverPreviewGroup component to display description in UI The description field is optional with a 2000 character limit and is included in group search functionality. * Fix TypeScript error: Add missing description prop to HoverPreviewGroup The HoverPreviewGroup component expects a description prop but it wasn't being passed from HoverPreview.tsx. This was causing the types check to fail with: error TS2741: Property 'description' is missing in type '{ ref: MutableRefObject; name: any; memberCount: any; users: any; }' but required in type 'Props'. Fixed by adding the description prop from data.description which is available in the UnfurlResponse[UnfurlResourceType.Group] type. * Move 2000 char validation to shared constant - Add GroupValidation.maxDescriptionLength constant to shared/validations.ts - Update server Group model to use GroupValidation.maxDescriptionLength - Update API schemas to use the shared constant instead of hardcoded value - Ensures consistent validation across the entire application * Add description field to CreateGroupDialog and EditGroupDialog - Add description textarea input to both create and edit group dialogs - Import GroupValidation constant for consistent character limit validation - Set maxLength to GroupValidation.maxDescriptionLength (2000 chars) - Include description in form submission for both create and update operations - Add placeholder text for better UX - Maintain backward compatibility with optional description field * Add description column to GroupsTable - Add description column between name and members columns - Display group description with fallback to em dash (—) for empty descriptions - Use secondary text styling for consistent visual hierarchy - Set column width to 2fr for adequate space - Maintain sortable functionality through accessor * tweaks * animation --------- Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com> Co-authored-by: Tom Moor --- app/components/Avatar/Avatar.tsx | 2 +- app/components/HoverPreview/HoverPreview.tsx | 22 +++++++++++- .../HoverPreview/HoverPreviewGroup.tsx | 29 ++++++++++----- app/models/Group.ts | 6 +++- .../Settings/components/GroupDialogs.tsx | 29 +++++++++++++-- .../Settings/components/GroupsTable.tsx | 36 +++++++------------ ...0251029200610-add-description-to-groups.js | 15 ++++++++ server/models/Group.ts | 5 +++ server/presenters/group.ts | 1 + server/presenters/unfurl.ts | 1 + server/routes/api/groups/schema.ts | 5 +++ shared/i18n/locales/en_US/translation.json | 10 +++--- shared/types.ts | 2 ++ shared/validations.ts | 5 +++ 14 files changed, 124 insertions(+), 44 deletions(-) create mode 100644 server/migrations/20251029200610-add-description-to-groups.js diff --git a/app/components/Avatar/Avatar.tsx b/app/components/Avatar/Avatar.tsx index 51bb49c94c..6b11ffadb5 100644 --- a/app/components/Avatar/Avatar.tsx +++ b/app/components/Avatar/Avatar.tsx @@ -71,7 +71,7 @@ function Avatar(props: Props) { ) : model ? ( - {model.initial} + {model.initial?.toUpperCase()} ) : ( diff --git a/app/components/HoverPreview/HoverPreview.tsx b/app/components/HoverPreview/HoverPreview.tsx index f0311b3f5d..3be4c32ca1 100644 --- a/app/components/HoverPreview/HoverPreview.tsx +++ b/app/components/HoverPreview/HoverPreview.tsx @@ -117,12 +117,31 @@ const HoverPreviewDesktop = observer( {isVisible ? ( {data.type === UnfurlResourceType.Mention ? ( diff --git a/app/components/HoverPreview/HoverPreviewGroup.tsx b/app/components/HoverPreview/HoverPreviewGroup.tsx index f62a8cf1f8..7ebfcaecdf 100644 --- a/app/components/HoverPreview/HoverPreviewGroup.tsx +++ b/app/components/HoverPreview/HoverPreviewGroup.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useTranslation } from "react-i18next"; import { UnfurlResourceType, UnfurlResponse } from "@shared/types"; import { MAX_AVATAR_DISPLAY } from "@shared/constants"; import User from "~/models/User"; @@ -17,21 +18,30 @@ import ErrorBoundary from "../ErrorBoundary"; type Props = Omit; const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup( - { name, memberCount, users }: Props, + { name, description, memberCount, users }: Props, ref: React.Ref ) { + const { t } = useTranslation(); + return ( - {name} - - {memberCount === 1 ? "1 member" : `${memberCount} members`} - - {users.length > 0 && ( - + + + {name} + + {t("{{ count }} members", { count: memberCount })} + + + {users.length > 0 && ( @@ -46,8 +56,9 @@ const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup( overflow={Math.max(0, memberCount - users.length)} limit={MAX_AVATAR_DISPLAY} /> - - )} + )} + + {description && {description}} diff --git a/app/models/Group.ts b/app/models/Group.ts index f8540e8731..be0940b8a2 100644 --- a/app/models/Group.ts +++ b/app/models/Group.ts @@ -12,6 +12,10 @@ class Group extends Model implements Searchable { @observable name: string; + @Field + @observable + description: string; + @observable externalId: string | undefined; @@ -33,7 +37,7 @@ class Group extends Model implements Searchable { @computed get searchContent(): string[] { - return [this.name].filter(Boolean); + return [this.name, this.description].filter(Boolean); } @computed diff --git a/app/scenes/Settings/components/GroupDialogs.tsx b/app/scenes/Settings/components/GroupDialogs.tsx index d1b9330e5b..04e18c6629 100644 --- a/app/scenes/Settings/components/GroupDialogs.tsx +++ b/app/scenes/Settings/components/GroupDialogs.tsx @@ -27,6 +27,7 @@ import useRequest from "~/hooks/useRequest"; import useStores from "~/hooks/useStores"; import InputMemberPermissionSelect from "~/components/InputMemberPermissionSelect"; import { GroupPermission } from "@shared/types"; +import { GroupValidation } from "@shared/validations"; import { EmptySelectValue, Permission } from "~/types"; import GroupUser from "~/models/GroupUser"; import Switch from "~/components/Switch"; @@ -40,6 +41,7 @@ export function CreateGroupDialog() { const { dialogs, groups } = useStores(); const { t } = useTranslation(); const [name, setName] = React.useState(); + const [description, setDescription] = React.useState(); const [isSaving, setIsSaving] = React.useState(false); const handleSubmit = React.useCallback( @@ -50,6 +52,7 @@ export function CreateGroupDialog() { const group = new Group( { name, + description, }, groups ); @@ -67,7 +70,7 @@ export function CreateGroupDialog() { setIsSaving(false); } }, - [t, dialogs, groups, name] + [t, dialogs, groups, name, description] ); return ( @@ -79,7 +82,7 @@ export function CreateGroupDialog() { example. - + + setDescription(e.target.value)} + value={description || ""} + maxLength={GroupValidation.maxDescriptionLength} + flex + /> You’ll be able to add people to the group next. @@ -104,6 +116,7 @@ export function CreateGroupDialog() { export function EditGroupDialog({ group, onSubmit }: Props) { const { t } = useTranslation(); const [name, setName] = React.useState(group.name); + const [description, setDescription] = React.useState(group.description || ""); const [disableMentions, setDisableMentions] = React.useState( group.disableMentions || false ); @@ -116,6 +129,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) { try { await group.save({ name, + description, disableMentions, }); onSubmit(); @@ -125,7 +139,7 @@ export function EditGroupDialog({ group, onSubmit }: Props) { setIsSaving(false); } }, - [group, onSubmit, name, disableMentions] + [group, onSubmit, name, description, disableMentions] ); const handleNameChange = React.useCallback( @@ -153,6 +167,15 @@ export function EditGroupDialog({ group, onSubmit }: Props) { autoFocus flex /> + setDescription(e.target.value)} + value={description} + maxLength={GroupValidation.maxDescriptionLength} + flex + /> group.description || "", + component: (group) => ( + + {group.description} + + ), + width: "2fr", + }, { type: "data", id: "members", @@ -97,30 +109,6 @@ export function GroupsTable(props: Props) { width: "1fr", sortable: false, }, - { - type: "data", - id: "admins", - header: t("Admins"), - accessor: (group) => `${group.memberCount} admins`, - component: (group) => { - const users = group.admins.slice(0, MAX_AVATAR_DISPLAY); - - if (users.length === 0) { - return null; - } - - return ( - handleViewMembers(group)} - width={users.length * AvatarSize.Large} - > - - - ); - }, - width: "1fr", - sortable: false, - }, { type: "data", id: "createdAt", diff --git a/server/migrations/20251029200610-add-description-to-groups.js b/server/migrations/20251029200610-add-description-to-groups.js new file mode 100644 index 0000000000..ed768e908e --- /dev/null +++ b/server/migrations/20251029200610-add-description-to-groups.js @@ -0,0 +1,15 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn("groups", "description", { + type: Sequelize.TEXT, + allowNull: true, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn("groups", "description"); + }, +}; diff --git a/server/models/Group.ts b/server/models/Group.ts index 2d508eb16a..fdba598396 100644 --- a/server/models/Group.ts +++ b/server/models/Group.ts @@ -9,6 +9,7 @@ import { DataType, Scopes, } from "sequelize-typescript"; +import { GroupValidation } from "@shared/validations"; import GroupMembership from "./GroupMembership"; import GroupUser from "./GroupUser"; import Team from "./Team"; @@ -65,6 +66,10 @@ class Group extends ParanoidModel< @Column name: string; + @Length({ min: 0, max: GroupValidation.maxDescriptionLength, msg: `description must be ${GroupValidation.maxDescriptionLength} characters or less` }) + @Column(DataType.TEXT) + description: string; + @Column externalId: string; diff --git a/server/presenters/group.ts b/server/presenters/group.ts index 6093674bf3..be7c732dae 100644 --- a/server/presenters/group.ts +++ b/server/presenters/group.ts @@ -4,6 +4,7 @@ export default async function presentGroup(group: Group) { return { id: group.id, name: group.name, + description: group.description, externalId: group.externalId, memberCount: await group.memberCount, disableMentions: group.disableMentions, diff --git a/server/presenters/unfurl.ts b/server/presenters/unfurl.ts index f9a08ff1fe..d332e3cf4e 100644 --- a/server/presenters/unfurl.ts +++ b/server/presenters/unfurl.ts @@ -65,6 +65,7 @@ const presentGroup = async ( return { type: UnfurlResourceType.Group, name: group.name, + description: group.description, memberCount, users: (data.users as User[]).map((user) => ({ id: user.id, diff --git a/server/routes/api/groups/schema.ts b/server/routes/api/groups/schema.ts index 38bc171973..aee4c07762 100644 --- a/server/routes/api/groups/schema.ts +++ b/server/routes/api/groups/schema.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { GroupPermission } from "@shared/types"; +import { GroupValidation } from "@shared/validations"; import { Group } from "@server/models"; const BaseIdSchema = z.object({ @@ -49,6 +50,8 @@ export const GroupsCreateSchema = z.object({ body: z.object({ /** Group name */ name: z.string(), + /** Group description */ + description: z.string().max(GroupValidation.maxDescriptionLength).optional(), /** Optionally link this group to an external source. */ externalId: z.string().optional(), /** Whether mentions are disabled for this group */ @@ -62,6 +65,8 @@ export const GroupsUpdateSchema = z.object({ body: BaseIdSchema.extend({ /** Group name */ name: z.string().optional(), + /** Group description */ + description: z.string().max(GroupValidation.maxDescriptionLength).optional(), /** Optionally link this group to an external source. */ externalId: z.string().optional(), /** Whether mentions are disabled for this group */ diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 9fdb6d85a6..32a4e8e0b2 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -291,6 +291,8 @@ "Filter options": "Filter options", "Filter": "Filter", "No results": "No results", + "{{ count }} members": "{{ count }} member", + "{{ count }} members_plural": "{{ count }} members", "{{authorName}} created <3>": "{{authorName}} created <3>", "{{authorName}} opened <3>": "{{authorName}} opened <3>", "Search emoji": "Search emoji", @@ -481,8 +483,6 @@ "Image height": "Image height", "Height": "Height", "Profile picture": "Profile picture", - "{{ count }} members": "{{ count }} member", - "{{ count }} members_plural": "{{ count }} members", "Create a new doc": "Create a new doc", "{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document", "Members of \"{{ groupName }}\" that have access to this document will be notified": "Members of \"{{ groupName }}\" that have access to this document will be notified", @@ -998,8 +998,10 @@ "Check server logs for more details.": "Check server logs for more details.", "{{userName}} requested": "{{userName}} requested", "Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.": "Groups are for organizing your team. They work best when centered around a function or a responsibility — Support or Engineering for example.", + "Optional": "Optional", "You’ll be able to add people to the group next.": "You’ll be able to add people to the group next.", "You can edit the name of this group at any time, however doing so too often might confuse your team mates.": "You can edit the name of this group at any time, however doing so too often might confuse your team mates.", + "Description": "Description", "Disable mentions": "Disable mentions", "Prevent this group from being mentionable in documents or comments": "Prevent this group from being mentionable in documents or comments", "Are you sure about that? Deleting the {{groupName}} group will cause its members to lose access to collections and documents that it is associated with.": "Are you sure about that? Deleting the {{groupName}} group will cause its members to lose access to collections and documents that it is associated with.", @@ -1020,7 +1022,6 @@ "No people left to add": "No people left to add", "Group admin": "Group admin", "Member": "Member", - "Admins": "Admins", "Date created": "Date created", "Crop Image": "Crop Image", "Crop image": "Crop image", @@ -1050,6 +1051,7 @@ "Domain": "Domain", "Views": "Views", "All roles": "All roles", + "Admins": "Admins", "Editors": "Editors", "All status": "All status", "Active": "Active", @@ -1064,7 +1066,6 @@ "The logo is displayed at the top left of the application.": "The logo is displayed at the top left of the application.", "Workspace logo": "Workspace logo", "The workspace name, usually the same as your company name.": "The workspace name, usually the same as your company name.", - "Description": "Description", "A short description of your workspace.": "A short description of your workspace.", "Theme": "Theme", "Customize the interface look and feel.": "Customize the interface look and feel.", @@ -1222,7 +1223,6 @@ "Deleting this version of the document will permanently and irrevocably remove it from the history.": "Deleting this version of the document will permanently and irrevocably remove it from the history.", "Format": "Format", "Add option": "Add option", - "Optional": "Optional", "Choose a size for your exported document": "Choose a size for your exported document", "Revision renamed": "Revision renamed", "Failed to save revision": "Failed to save revision", diff --git a/shared/types.ts b/shared/types.ts index ee48bac5ff..c8889e3bad 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -452,6 +452,8 @@ export type UnfurlResponse = { type: UnfurlResourceType.Group; /** Group name */ name: string; + /** Group description */ + description: string | null; /** Number of members in the group */ memberCount: number; /** Array of group members (limited to display count) */ diff --git a/shared/validations.ts b/shared/validations.ts index a0bffa2855..f8ee43a3c8 100644 --- a/shared/validations.ts +++ b/shared/validations.ts @@ -54,6 +54,11 @@ export const DocumentValidation = { maxRecommendedLength: 250000, }; +export const GroupValidation = { + /** The maximum length of the group description */ + maxDescriptionLength: 2000, +}; + export const ImportValidation = { /** The maximum length of the import name */ maxNameLength: 100,