Compare commits

...

7 Commits

Author SHA1 Message Date
Tom Moor 540b514702 fix: Multiplayer provider does not emit authenticationFailed for all instances 2025-11-03 20:22:48 -05:00
Tom Moor e4268c9a1f chore: Public share cleanup (#10541)
* chore: More public share cleanup

* fix: Use correct amount of spaces for tab

* fix: Pointer on public shares

* fix: Tweak AAA contrast

* Show code language on public share
2025-11-01 10:25:38 -04:00
codegen-sh[bot] bf9065d6e6 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>
2025-10-31 11:36:26 -04:00
Apoorv Mishra 3e5ae49ad9 Link bar cleanup (#10522)
* fix: link bar bugs

* fix: restore click on search results

* fix: esc

* fix: comment
2025-10-31 17:57:11 +05:30
Tom Moor 9a4d754a39 Improved syntax highlighting (#10533)
* Improve syntax highlighting

* fixes

* fix
2025-10-31 07:30:10 -04:00
Tom Moor 0009a08278 Add group member count to mention menu (#10535)
* Add group member count to mention menu

* i18n
2025-10-31 07:30:01 -04:00
Tom Moor 84bc914940 Hide collection root if empty (#10534) 2025-10-31 07:29:50 -04:00
29 changed files with 332 additions and 180 deletions
+1 -1
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} />
+21 -1
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}
/>
@@ -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>
+15 -4
View File
@@ -23,6 +23,7 @@ import { SharedDocumentLink } from "./components/SharedDocumentLink";
import SidebarButton from "./components/SidebarButton";
import ToggleButton from "./components/ToggleButton";
import { useEffect } from "react";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
type Props = {
share: Share;
@@ -31,12 +32,16 @@ type Props = {
function SharedSidebar({ share }: Props) {
const team = useTeamContext();
const user = useCurrentUser({ rejectOnEmpty: false });
const { ui, documents } = useStores();
const { ui, documents, collections } = useStores();
const { t } = useTranslation();
const teamAvailable = !!team?.name;
const rootNode = share.tree;
const shareId = share.urlId || share.id;
const collection = collections.get(rootNode?.id);
const hideRootNode = collection
? ProsemirrorHelper.isEmptyData(collection?.data)
: false;
useEffect(() => {
ui.tocVisible = share.showTOC;
@@ -54,8 +59,10 @@ function SharedSidebar({ share }: Props) {
image={
<TeamLogo model={team} size={AvatarSize.XLarge} alt={t("Logo")} />
}
onClick={() =>
history.push(user ? homePath() : sharedModelPath(shareId))
onClick={
hideRootNode
? undefined
: () => history.push(user ? homePath() : sharedModelPath(shareId))
}
/>
)}
@@ -72,7 +79,11 @@ function SharedSidebar({ share }: Props) {
</TopSection>
<Section>
{share.collectionId ? (
<SharedCollectionLink node={rootNode} shareId={shareId} />
<SharedCollectionLink
node={rootNode}
shareId={shareId}
hideRootNode={hideRootNode}
/>
) : (
<SharedDocumentLink
index={0}
@@ -10,35 +10,37 @@ import SidebarLink from "./SidebarLink";
type Props = {
node: NavigationNode;
shareId: string;
hideRootNode?: boolean;
};
function CollectionLink({ node, shareId }: Props) {
function CollectionLink({ node, shareId, hideRootNode }: Props) {
const { t } = useTranslation();
const { documents, ui } = useStores();
const icon = node.icon ?? node.emoji;
return (
<>
<SidebarLink
to={{
pathname: sharedModelPath(shareId),
state: {
title: node.title,
},
}}
icon={icon && <Icon value={icon} color={node.color} />}
label={node.title || t("Untitled")}
depth={0}
exact={false}
scrollIntoViewIfNeeded={true}
isActive={() => ui.activeCollectionId === node.id}
/>
{!hideRootNode && (
<SidebarLink
to={{
pathname: sharedModelPath(shareId),
state: {
title: node.title,
},
}}
icon={icon && <Icon value={icon} color={node.color} />}
label={node.title || t("Untitled")}
depth={0}
exact={false}
scrollIntoViewIfNeeded={true}
isActive={() => ui.activeCollectionId === node.id}
/>
)}
{node.children.map((childNode, index) => (
<SharedDocumentLink
key={childNode.id}
index={index}
depth={2}
depth={hideRootNode ? 0 : 2}
shareId={shareId}
node={childNode}
prefetchDocument={documents.prefetchDocument}
@@ -25,6 +25,7 @@ const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
image,
title,
children,
onClick,
...rest
}: SidebarButtonProps,
ref
@@ -38,10 +39,12 @@ const SidebarButton = React.forwardRef<HTMLButtonElement, SidebarButtonProps>(
>
<Button
{...rest}
onClick={onClick}
$position={position}
as="button"
ref={ref}
role="button"
disabled={!onClick}
>
<Content gap={8} align="center">
{image}
@@ -96,17 +99,17 @@ const Button = styled(Flex)<{
text-decoration: none;
text-align: left;
user-select: none;
cursor: var(--pointer);
position: relative;
${undraggableOnDesktop()}
${extraArea(4)}
&:active,
&:${hover},
&[aria-expanded="true"] {
&:not(:disabled):active,
&:not(:disabled):${hover},
&:not(:disabled)[aria-expanded="true"] {
color: ${s("sidebarText")};
background: ${s("sidebarActiveBackground")};
cursor: var(--pointer);
}
&:last-child {
-2
View File
@@ -1,5 +1,4 @@
import styled from "styled-components";
import { s } from "@shared/styles";
import { Avatar } from "./Avatar";
import { AvatarVariant } from "./Avatar/Avatar";
@@ -7,7 +6,6 @@ const TeamLogo = styled(Avatar).attrs({
variant: AvatarVariant.Square,
})`
border-radius: 4px;
box-shadow: inset 0 0 0 1px ${s("divider")};
border: 0;
`;
+4 -3
View File
@@ -30,9 +30,10 @@ const Theme: React.FC = ({ children }: Props) => {
<ThemeProvider theme={theme}>
<>
<GlobalStyles
useCursorPointer={auth.user?.getPreference(
UserPreference.UseCursorPointer
)}
useCursorPointer={
// Default to showing the cursor pointer if no user is logged in (public share)
auth.user?.getPreference(UserPreference.UseCursorPointer) ?? true
}
/>
{children}
</>
+27 -52
View File
@@ -22,13 +22,13 @@ import Input from "./Input";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
import ToolbarButton from "./ToolbarButton";
import Tooltip from "./Tooltip";
import useOnClickOutside from "~/hooks/useOnClickOutside";
type Props = {
mark?: Mark;
from: number;
to: number;
dictionary: Dictionary;
onRemoveLink?: () => void;
onSelectLink: (options: {
href: string;
title?: string;
@@ -47,16 +47,14 @@ const LinkEditor: React.FC<Props> = ({
from,
to,
dictionary,
onRemoveLink,
onSelectLink,
onClickLink,
view,
}) => {
const getHref = () => sanitizeUrl(mark?.attrs.href) ?? "";
const initialValue = getHref();
const initialSelectionLength = to - from;
const inputRef = useRef<HTMLInputElement>(null);
const discardRef = useRef(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState(initialValue);
const [selectedIndex, setSelectedIndex] = useState(-1);
const { documents } = useStores();
@@ -79,35 +77,19 @@ const LinkEditor: React.FC<Props> = ({
}
}, [trimmedQuery, request]);
useEffect(() => {
const handleGlobalKeyDown = (event: KeyboardEvent) => {
if (event.key === "k" && event.metaKey) {
inputRef.current?.select();
}
};
useOnClickOutside(wrapperRef, () => {
// If the link in input is non-empty and same as it was when the editor opened, nothing to do
if (trimmedQuery.length && trimmedQuery === initialValue) {
return;
}
window.addEventListener("keydown", handleGlobalKeyDown);
return () => {
window.removeEventListener("keydown", handleGlobalKeyDown);
// If the link is totally empty or only spaces then remove the mark
if (!trimmedQuery) {
return handleRemoveLink();
}
// If we discarded the changes then nothing to do
if (discardRef.current) {
return;
}
// If the link is the same as it was when the editor opened, nothing to do
if (trimmedQuery === initialValue) {
return;
}
// If the link is totally empty or only spaces then remove the mark
if (!trimmedQuery) {
return handleRemoveLink();
}
save(trimmedQuery, trimmedQuery);
};
}, [trimmedQuery, initialValue]);
save(trimmedQuery, trimmedQuery);
});
const save = (href: string, title?: string) => {
href = href.trim();
@@ -116,10 +98,10 @@ const LinkEditor: React.FC<Props> = ({
return;
}
discardRef.current = true;
href = sanitizeUrl(href) ?? "";
onSelectLink({ href, title, from, to });
moveSelectionToEnd();
};
const moveSelectionToEnd = () => {
@@ -156,20 +138,20 @@ const LinkEditor: React.FC<Props> = ({
save(trimmedQuery, trimmedQuery);
}
if (initialSelectionLength) {
moveSelectionToEnd();
}
return;
}
case "Escape": {
event.preventDefault();
if (initialValue) {
setQuery(initialValue);
moveSelectionToEnd();
} else {
if (!initialValue) {
handleRemoveLink();
}
// Moving selection to end causes editor state to change,
// forcing a re-render of the top-level editor component. As
// a result, the new selection, being devoid of any link mark,
// prevents LinkEditor from re-rendering.
moveSelectionToEnd();
return;
}
}
@@ -196,23 +178,19 @@ const LinkEditor: React.FC<Props> = ({
};
const handleRemoveLink = () => {
discardRef.current = true;
const { state, dispatch } = view;
if (mark) {
dispatch(state.tr.removeMark(from, to, mark));
}
onRemoveLink?.();
view.focus();
moveSelectionToEnd();
};
const isInternal = isInternalUrl(query);
const hasResults = !!results.length;
return (
<>
<Wrapper>
<div ref={wrapperRef}>
<InputWrapper ref={wrapperRef}>
<Input
ref={inputRef}
value={query}
@@ -238,7 +216,7 @@ const LinkEditor: React.FC<Props> = ({
</ToolbarButton>
</Tooltip>
)}
</Wrapper>
</InputWrapper>
<SearchResults $hasResults={hasResults}>
<ResizingHeightContainer>
{hasResults && (
@@ -247,9 +225,6 @@ const LinkEditor: React.FC<Props> = ({
<SuggestionsMenuItem
onClick={() => {
save(doc.url, doc.title);
if (initialSelectionLength) {
moveSelectionToEnd();
}
}}
onPointerMove={() => setSelectedIndex(index)}
selected={index === selectedIndex}
@@ -276,11 +251,11 @@ const LinkEditor: React.FC<Props> = ({
)}
</ResizingHeightContainer>
</SearchResults>
</>
</div>
);
};
const Wrapper = styled(Flex)`
const InputWrapper = styled(Flex)`
pointer-events: all;
gap: 6px;
padding: 6px;
+1
View File
@@ -123,6 +123,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
</Flex>
),
title: group.name,
subtitle: t("{{ count }} members", { count: group.memberCount }),
section: GroupSection,
appendSpace: true,
attrs: {
+14 -12
View File
@@ -30,31 +30,33 @@ export default function codeMenuItems(
)
.map(([value, item]) => langToMenuItem({ node, value, label: item.label }));
const languageMenuItems = frequentLangMenuItems.length
? [
...frequentLangMenuItems,
{ name: "separator" },
...remainingLangMenuItems,
]
: remainingLangMenuItems;
const getLanguageMenuItems = () =>
frequentLangMenuItems.length
? [
...frequentLangMenuItems,
{ name: "separator" },
...remainingLangMenuItems,
]
: remainingLangMenuItems;
return [
{
name: "copyToClipboard",
icon: <CopyIcon />,
label: readOnly ? dictionary.copy : undefined,
label: readOnly
? getLabelForLanguage(node.attrs.language ?? "none")
: undefined,
tooltip: dictionary.copy,
},
{
name: "separator",
visible: !readOnly,
},
{
visible: !readOnly,
name: "code_block",
icon: <ExpandedIcon />,
label: getLabelForLanguage(node.attrs.language ?? "none"),
children: languageMenuItems,
icon: <ExpandedIcon />,
children: getLanguageMenuItems(),
visible: !readOnly,
},
];
}
+5 -1
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
@@ -164,6 +164,11 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
if (ev.event.code === EditorUpdateError.code) {
setEditorVersionBehind(true);
}
if (ev.event.code === 4403) {
void auth.fetchAuth().catch(() => {
history.replace(homePath());
});
}
}
});
@@ -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")}
+12 -24
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",
+25 -18
View File
@@ -3,7 +3,31 @@ import "styled-components";
// and extend them!
declare module "styled-components" {
interface EditorTheme {
interface CodeTheme {
code: string;
codeComment: string;
codePunctuation: string;
codeNumber: string;
codeProperty: string;
codeTag: string;
codeString: string;
codeClassName: string;
codeConstant: string;
codeParameter: string;
codeSelector: string;
codeAttrName: string;
codeAttrValue: string;
codeEntity: string;
codeKeyword: string;
codeFunction: string;
codeStatement: string;
codePlaceholder: string;
codeInserted: string;
codeImportant: string;
codeOperator: string;
}
interface EditorTheme extends CodeTheme {
isDark: boolean;
background: string;
text: string;
@@ -29,23 +53,6 @@ declare module "styled-components" {
textHighlight: string;
textHighlightForeground: string;
selected: string;
code: string;
codeComment: string;
codePunctuation: string;
codeNumber: string;
codeProperty: string;
codeTag: string;
codeString: string;
codeClassName: string;
codeSelector: string;
codeAttr: string;
codeEntity: string;
codeKeyword: string;
codeFunction: string;
codeStatement: string;
codePlaceholder: string;
codeInserted: string;
codeImportant: string;
noticeInfoBackground: string;
noticeInfoText: string;
noticeTipBackground: string;
@@ -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");
},
};
+5
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;
+1
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,
+1
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,
+5
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 */
+1 -1
View File
@@ -86,7 +86,7 @@ router.post(
ctx.body = {
data: {
shares: [presentShare(share, user?.isAdmin ?? false)],
sharedTree: sharedTree,
sharedTree,
team: serializedTeam,
collection: serializedCollection,
document: serializedDocument,
+19 -2
View File
@@ -1,12 +1,12 @@
import { exitCode } from "prosemirror-commands";
import { Command, TextSelection } from "prosemirror-state";
import { Command, EditorState, TextSelection } from "prosemirror-state";
import { findNextNewline, findPreviousNewline } from "../queries/findNewlines";
import { isInCode } from "../queries/isInCode";
import { findParentNode } from "../queries/findParentNode";
import { isCode } from "../lib/isCode";
import { languagesWithFourSpaceIndent } from "../lib/code";
const newline = "\n";
const tabSize = 2;
/**
* Moves the current selection to the previous newline, this is used inside
@@ -93,6 +93,7 @@ export const indentInCode: Command = (state, dispatch) => {
return false;
}
const tabSize = getTabSize(state);
const spaces = " ".repeat(tabSize);
const { tr, selection } = state;
const { $from, from, to } = selection;
@@ -155,6 +156,7 @@ export const outdentInCode: Command = (state, dispatch) => {
let totalSpacesRemoved = 0;
let spacesRemovedOnFirstLine = 0;
const startOfFirstLine = findPreviousNewline($from);
const tabSize = getTabSize(state);
while (index >= startOfFirstLine - line * tabSize) {
const newLineBefore =
@@ -268,3 +270,18 @@ export const splitCodeBlockOnTripleBackticks: Command = (state, dispatch) => {
return true;
};
function getTabSize(state: EditorState): number {
const codeBlock = findParentNode(isCode)(state.selection);
if (!codeBlock) {
return 2;
}
if (languagesWithFourSpaceIndent.includes(codeBlock.node.attrs.language)) {
return 4;
}
const existingText = codeBlock.node.textContent;
const usesFourSpaces = existingText.includes(" ");
return usesFourSpaces ? 4 : 2;
}
+28 -4
View File
@@ -163,13 +163,13 @@ const codeBlockStyle = (props: Props) => css`
opacity: 0.7;
}
.token.operator,
.token.boolean,
.token.number {
color: ${props.theme.codeNumber};
}
.token.property {
.token.property,
.token.variable {
color: ${props.theme.codeProperty};
}
@@ -177,6 +177,8 @@ const codeBlockStyle = (props: Props) => css`
color: ${props.theme.codeTag};
}
.token.char,
.token.builtin,
.token.string {
color: ${props.theme.codeString};
}
@@ -186,7 +188,20 @@ const codeBlockStyle = (props: Props) => css`
}
.token.attr-name {
color: ${props.theme.codeAttr};
color: ${props.theme.codeAttrName};
}
.token.attr-value,
.token.attr-value .token.punctuation {
color: ${props.theme.codeAttrValue};
}
.token.operator {
color: ${props.theme.codeOperator};
}
.token.namespace {
opacity: 0.8;
}
.token.entity,
@@ -242,6 +257,14 @@ const codeBlockStyle = (props: Props) => css`
font-weight: bold;
}
.token.constant {
color: ${props.theme.codeConstant};
}
.token.parameter {
color: ${props.theme.codeParameter};
}
.token.important {
color: ${props.theme.codeImportant};
}
@@ -1331,12 +1354,13 @@ code {
border: 1px solid ${props.theme.codeBorder};
background: ${props.theme.codeBackground};
padding: 3px 4px;
color: ${props.theme.codeString};
color: ${props.theme.code};
font-family: ${props.theme.fontFamilyMono};
font-size: 90%;
.${EditorStyleHelper.codeWord} {
white-space: nowrap;
color: ${props.theme.codeKeyword};
}
}
+8
View File
@@ -425,3 +425,11 @@ export const getFrequentCodeLanguages = () => {
const sortFrequencies = <T>(freqs: [T, number][]) =>
freqs.sort((a, b) => (a[1] >= b[1] ? -1 : 1));
export const languagesWithFourSpaceIndent = [
"python",
"java",
"cpp",
"csharp",
"rust",
];
+5 -3
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",
@@ -996,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.",
@@ -1018,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",
@@ -1048,6 +1051,7 @@
"Domain": "Domain",
"Views": "Views",
"All roles": "All roles",
"Admins": "Admins",
"Editors": "Editors",
"All status": "All status",
"Active": "Active",
@@ -1062,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.",
@@ -1220,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",
+35 -19
View File
@@ -73,25 +73,29 @@ const buildBaseTheme = (input: Partial<Colors>) => {
textHighlightForeground: colors.almostBlack,
commentMarkBackground: transparentize(0.5, "#2BC2FF"),
code: colors.lightBlack,
codeComment: "#6a737d",
codePunctuation: "#5e6687",
codeNumber: "#d73a49",
codeProperty: "#c08b30",
codeTag: "#3d8fd1",
codeClassName: "#3d8fd1",
codeString: "#032f62",
codeSelector: "#6679cc",
codeAttr: "#c76b29",
codeEntity: "#22a2c9",
codeKeyword: "#d73a49",
codeFunction: "#6f42c1",
codeStatement: "#22a2c9",
codeComment: "#008000",
codePunctuation: "#393a34",
codeNumber: "#0550ae",
codeProperty: "#ff0000",
codeTag: "#800000",
codeClassName: "#00578a",
codeString: "#a31515",
codeSelector: "#800000",
codeAttrName: "#ff0000",
codeAttrValue: colors.lightBlack,
codeEntity: "#ff0000",
codeKeyword: "#00009f",
codeFunction: "#393A34",
codeStatement: "#ff0000",
codePlaceholder: "#3d8fd1",
codeInserted: "#202746",
codeImportant: "#c94922",
codeInserted: "#0550ae",
codeImportant: "#e90e90",
codeConstant: "#0550ae",
codeParameter: colors.lightBlack,
codeOperator: "#393a34",
noticeInfoBackground: colors.brand.blue,
noticeInfoText: colors.almostBlack,
noticeTipBackground: "#F5BE31",
noticeTipBackground: "#f5be31",
noticeTipText: colors.almostBlack,
noticeWarningBackground: "#d73a49",
noticeWarningText: colors.almostBlack,
@@ -223,13 +227,25 @@ export const buildDarkTheme = (input: Partial<Colors>): DefaultTheme => {
code: colors.almostWhite,
codeBackground: "#1d202a",
codeBorder: colors.white10,
codeComment: "#6a9955",
codePunctuation: "#b3b3b3",
codeProperty: "#b5cea8",
codeNumber: "#b5cea8",
codeTag: "#b5cea8",
codeOperator: "#d4d4d4",
codeConstant: "#9cdcfe",
codeParameter: "#9cdcfe",
codeSelector: "#ce9178",
codeEntity: "#d4d4d4",
codeStatement: "#d16969",
codeInserted: "#b5cea8",
codeString: "#ce9178",
codeKeyword: "#569CD6",
codeKeyword: "#569Cd6",
codeFunction: "#dcdcaa",
codeClassName: "#4ec9b0",
codeImportant: "#569CD6",
codeAttr: "#9cdcfe",
codeImportant: "#569Cd6",
codeAttrName: "#9cdcfe",
codeAttrValue: "#ce9178",
embedBorder: colors.black50,
horizontalRule: lighten(0.1, colors.almostBlack),
noticeInfoText: colors.white,
+2
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) */
+5
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,