Add description column to groups (#10511)

* 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<HTMLDivElement | null>; 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 <tom@getoutline.com>
This commit is contained in:
codegen-sh[bot]
2025-10-31 11:36:26 -04:00
committed by GitHub
parent 3e5ae49ad9
commit bf9065d6e6
14 changed files with 124 additions and 44 deletions

View File

@@ -71,7 +71,7 @@ function Avatar(props: Props) {
<Image onError={handleError} src={src} {...rest} />
) : model ? (
<Initials color={model.color} {...rest}>
{model.initial}
{model.initial?.toUpperCase()}
</Initials>
) : (
<Initials {...rest} />

View File

@@ -117,12 +117,31 @@ const HoverPreviewDesktop = observer(
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
initial={{
opacity: 0,
y: -20,
filter: "blur(5px)",
pointerEvents: "none",
}}
animate={{
opacity: 1,
y: 0,
filter: "blur(0px)",
transitionEnd: { pointerEvents: "auto" },
}}
transition={{
y: {
type: "spring",
stiffness: 400,
damping: 25,
},
opacity: {
duration: 0.2,
},
filter: {
duration: 0.2,
},
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
@@ -137,6 +156,7 @@ const HoverPreviewDesktop = observer(
<HoverPreviewGroup
ref={cardRef}
name={data.name}
description={data.description}
memberCount={data.memberCount}
users={data.users}
/>

View File

@@ -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<UnfurlResponse[UnfurlResourceType.Group], "type">;
const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup(
{ name, memberCount, users }: Props,
{ name, description, memberCount, users }: Props,
ref: React.Ref<HTMLDivElement>
) {
const { t } = useTranslation();
return (
<Preview as="div">
<Card fadeOut={false} ref={ref}>
<CardContent>
<ErrorBoundary showTitle={false} reloadOnChunkMissing={false}>
<Flex column gap={2} align="start">
<Title>{name}</Title>
<Info>
{memberCount === 1 ? "1 member" : `${memberCount} members`}
</Info>
{users.length > 0 && (
<Description>
<Flex
justify="space-between"
gap={4}
style={{ width: "100%" }}
auto
>
<Flex column align="start">
<Title>{name}</Title>
<Info>
{t("{{ count }} members", { count: memberCount })}
</Info>
</Flex>
{users.length > 0 && (
<Facepile
users={users.map(
(member) =>
@@ -46,8 +56,9 @@ const HoverPreviewGroup = React.forwardRef(function _HoverPreviewGroup(
overflow={Math.max(0, memberCount - users.length)}
limit={MAX_AVATAR_DISPLAY}
/>
</Description>
)}
)}
</Flex>
{description && <Description>{description}</Description>}
</Flex>
</ErrorBoundary>
</CardContent>

View File

@@ -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

View File

@@ -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<string | undefined>();
const [description, setDescription] = React.useState<string | undefined>();
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.
</Trans>
</Text>
<Flex>
<Flex column>
<Input
type="text"
label="Name"
@@ -89,6 +92,15 @@ export function CreateGroupDialog() {
autoFocus
flex
/>
<Input
type="textarea"
label="Description"
placeholder={t("Optional")}
onChange={(e) => setDescription(e.target.value)}
value={description || ""}
maxLength={GroupValidation.maxDescriptionLength}
flex
/>
</Flex>
<Text as="p" type="secondary">
<Trans>Youll be able to add people to the group next.</Trans>
@@ -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
/>
<Input
type="textarea"
label={t("Description")}
placeholder={t("Optional")}
onChange={(e) => setDescription(e.target.value)}
value={description}
maxLength={GroupValidation.maxDescriptionLength}
flex
/>
<Switch
id="mentions"
label={t("Disable mentions")}

View File

@@ -70,6 +70,18 @@ export function GroupsTable(props: Props) {
),
width: "2fr",
},
{
type: "data",
id: "description",
header: t("Description"),
accessor: (group) => group.description || "",
component: (group) => (
<Text type="secondary" size="small" weight="normal">
{group.description}
</Text>
),
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 (
<GroupMembers
onClick={() => handleViewMembers(group)}
width={users.length * AvatarSize.Large}
>
<Facepile users={users} />
</GroupMembers>
);
},
width: "1fr",
sortable: false,
},
{
type: "data",
id: "createdAt",

View File

@@ -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");
},
};

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 */

View File

@@ -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></3>": "{{authorName}} created <3></3>",
"{{authorName}} opened <3></3>": "{{authorName}} opened <3></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",
"Youll be able to add people to the group next.": "Youll 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 <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Are you sure about that? Deleting the <em>{{groupName}}</em> 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",

View File

@@ -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) */

View File

@@ -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,