mirror of
https://github.com/outline/outline.git
synced 2025-12-21 10:39:41 -06:00
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:
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>You’ll 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")}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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",
|
||||
"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 <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",
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user