Compare commits

...

5 Commits

Author SHA1 Message Date
Tom Moor b70453d36b Display option on each tab 2025-03-07 13:14:48 -05:00
Tom Moor d79f40e522 Link timestamp 2025-03-07 13:09:54 -05:00
Tom Moor baa79337fb Add selection ui 2025-03-07 12:56:06 -05:00
Tom Moor 692c7ca8a2 Merge branch 'main' into tom/collection-display-options 2025-03-07 11:06:45 -05:00
Tom Moor 97f65ae250 wip 2025-02-04 19:04:55 -05:00
16 changed files with 415 additions and 29 deletions
+85 -4
View File
@@ -1,11 +1,13 @@
import { observer } from "mobx-react";
import { BulletedListIcon, MenuIcon } from "outline-icons";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { randomElement } from "@shared/random";
import { CollectionPermission } from "@shared/types";
import { s } from "@shared/styles";
import { CollectionDisplay, CollectionPermission } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
@@ -13,12 +15,13 @@ import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission";
import InputSelect from "~/components/InputSelect";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { EmptySelectValue } from "~/types";
import { Label } from "../Labeled";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
@@ -27,6 +30,7 @@ export interface FormData {
icon: string;
color: string | null;
sharing: boolean;
display: CollectionDisplay | undefined;
permission: CollectionPermission | undefined;
}
@@ -62,6 +66,7 @@ export const CollectionForm = observer(function CollectionForm_({
defaultValues: {
name: collection?.name ?? "",
icon: collection?.icon,
display: collection?.display ?? CollectionDisplay.List,
sharing: collection?.sharing ?? true,
permission: collection?.permission,
color: iconColor,
@@ -139,9 +144,25 @@ export const CollectionForm = observer(function CollectionForm_({
control={control}
name="permission"
render={({ field }) => (
<InputSelectPermission
<InputSelect
ref={field.ref}
value={field.value}
label={t("Permission")}
options={[
{
label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("Can edit"),
value: CollectionPermission.ReadWrite,
},
{
label: t("No access"),
value: EmptySelectValue,
},
]}
ariaLabel={t("Default access")}
value={field.value || EmptySelectValue}
onChange={(
value: CollectionPermission | typeof EmptySelectValue
) => {
@@ -155,6 +176,44 @@ export const CollectionForm = observer(function CollectionForm_({
/>
)}
<Controller
control={control}
name="display"
render={({ field }) => (
<div style={{ marginBottom: 8 }}>
<Label>{t("Display")}</Label>
<Flex gap={8}>
<Toggle>
<Text weight="bold" as={Flex} gap={4} align="center">
<MenuIcon /> {t("List")}
</Text>
<Text type="secondary">{t("Show only titles")}</Text>
<input
type="radio"
name="display"
value={CollectionDisplay.List}
checked={field.value === CollectionDisplay.List}
onClick={(ev) => field.onChange(ev.currentTarget.value)}
/>
</Toggle>
<Toggle>
<Text weight="bold" as={Flex} gap={4} align="center">
<BulletedListIcon /> {t("Post")}
</Text>
<Text type="secondary">{t("Show document preview")}</Text>
<input
type="radio"
name="display"
value={CollectionDisplay.Post}
checked={field.value === CollectionDisplay.Post}
onClick={(ev) => field.onChange(ev.currentTarget.value)}
/>
</Toggle>
</Flex>
</div>
)}
/>
{team.sharing && (
<Switch
id="sharing"
@@ -188,3 +247,25 @@ const StyledIconPicker = styled(IconPicker)`
margin-left: 4px;
margin-right: 4px;
`;
const Toggle = styled.label`
display: flex;
flex-direction: column;
flex-grow: 1;
border-radius: 4px;
border: 1px solid ${s("inputBorder")};
font-size: 14px;
padding: 8px;
margin-bottom: 12px;
width: 50%;
cursor: var(--pointer);
input {
display: none;
}
&:has(input:checked) {
border-color: ${s("accent")};
box-shadow: 0 0 0 1px ${s("accent")};
}
`;
+217
View File
@@ -0,0 +1,217 @@
import {
useFocusEffect,
useRovingTabIndex,
} from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { richExtensions, withComments } from "@shared/editor/nodes";
import { s, hover } from "@shared/styles";
import Document from "~/models/Document";
import Badge from "~/components/Badge";
import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight";
import NudeButton from "~/components/NudeButton";
import StarButton from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { documentPath } from "~/utils/routeHelpers";
import { Avatar, AvatarSize } from "./Avatar";
import Editor from "./Editor";
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
import Text from "./Text";
import Time from "./Time";
const extensions = withComments(richExtensions);
type Props = {
document: Document;
highlight?: string | undefined;
context?: string | undefined;
showParentDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
showPin?: boolean;
showDraft?: boolean;
};
function DocumentPostItem(
props: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const { t } = useTranslation();
const user = useCurrentUser();
const locationSidebarContext = useLocationSidebarContext();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
if (ref) {
itemRef = ref;
}
const { focused, ...rovingTabIndex } = useRovingTabIndex(itemRef, false);
useFocusEffect(focused, itemRef);
const {
document,
showParentDocuments,
showCollection,
showPublished,
showPin,
showDraft = true,
highlight,
context,
...rest
} = props;
const canStar = !document.isArchived && !document.isTemplate;
const sidebarContext = determineSidebarContext({
document,
user,
currentContext: locationSidebarContext,
});
const to = React.useMemo(
() => ({
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}),
[document, sidebarContext]
);
return (
<Post
dir={document.dir}
role="menuitem"
$isStarred={document.isStarred}
$menuOpen={menuOpen}
{...rest}
{...rovingTabIndex}
>
<Content>
<Heading ref={itemRef} dir={document.dir} to={to}>
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
<Actions>
<DocumentMenu
document={document}
showPin={showPin}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
modal={false}
/>
</Actions>
</Heading>
<Flex justify="space-between" style={{ marginBottom: 8 }}>
<Flex gap={6} align="center">
<Avatar model={document.createdBy} size={AvatarSize.Medium} />
<Text type="secondary" size="small">
{t("By {{ author }}", { author: document.createdBy?.name })}{" "}
<Link to={to}>
<Text type="secondary" size="small">
<Time dateTime={document.updatedAt} addSuffix />
</Text>
</Link>
</Text>
</Flex>
</Flex>
<Editor defaultValue={document.data} extensions={extensions} readOnly />
</Content>
</Post>
);
}
const Content = styled.div`
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
`;
const Actions = styled(EventBoundary)`
display: none;
align-items: center;
margin: 8px;
flex-shrink: 0;
flex-grow: 0;
color: ${s("textSecondary")};
${NudeButton} {
&: ${hover}, &[aria-expanded= "true"] {
background: ${s("sidebarControlHoverBackground")};
}
}
${breakpoint("tablet")`
display: flex;
`};
`;
const Post = styled.div<{
$isStarred?: boolean;
$menuOpen?: boolean;
}>`
position: relative;
margin-top: 10px;
margin-bottom: 3em;
padding: 0;
`;
const Heading = styled(Link)<{ rtl?: boolean }>`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
margin-top: -2px;
margin-bottom: -4px;
white-space: nowrap;
color: ${s("text")};
font-size: 20px;
font-family: ${s("fontFamily")};
font-weight: 500;
width: 100%;
`;
const StarPositioner = styled(Flex)`
margin-left: 4px;
align-items: center;
`;
const Title = styled(Highlight)`
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
&: ${hover} {
text-decoration: underline;
}
`;
export default observer(React.forwardRef(DocumentPostItem));
+2
View File
@@ -324,6 +324,8 @@ const StyledButton = styled(Button)<{ $nude?: boolean }>`
display: block;
width: 100%;
cursor: var(--pointer);
background: ${s("background")};
border-radius: 4px;
&:hover:not(:disabled) {
background: ${s("buttonNeutralBackground")};
+28 -12
View File
@@ -1,9 +1,11 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { CollectionDisplay } from "@shared/types";
import Document from "~/models/Document";
import DocumentListItem from "~/components/DocumentListItem";
import Error from "~/components/List/Error";
import PaginatedList from "~/components/PaginatedList";
import DocumentPostItem from "./DocumentPostItem";
type Props = {
documents: Document[];
@@ -11,6 +13,7 @@ type Props = {
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
display?: CollectionDisplay;
showParentDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
@@ -24,6 +27,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
documents,
fetch,
options,
display = CollectionDisplay.List,
showParentDocuments,
showCollection,
showPublished,
@@ -42,18 +46,30 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
fetch={fetch}
options={options}
renderError={(props) => <Error {...props} />}
renderItem={(item: Document, _index) => (
<DocumentListItem
key={item.id}
document={item}
showPin={!!options?.collectionId}
showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
showTemplate={showTemplate}
showDraft={showDraft}
/>
)}
renderItem={(item: Document, _index) =>
display === CollectionDisplay.List ? (
<DocumentListItem
key={item.id}
document={item}
showPin={!!options?.collectionId}
showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
showTemplate={showTemplate}
showDraft={showDraft}
/>
) : (
<DocumentPostItem
key={item.id}
document={item}
showPin={!!options?.collectionId}
showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
showDraft={showDraft}
/>
)
}
{...rest}
/>
);
+6
View File
@@ -1,6 +1,7 @@
import invariant from "invariant";
import { action, computed, observable, runInAction } from "mobx";
import {
CollectionDisplay,
CollectionPermission,
FileOperationFormat,
type NavigationNode,
@@ -42,6 +43,11 @@ export default class Collection extends ParanoidModel {
@observable
color?: string | null;
/** The display mode of the collection index. */
@Field
@observable
display: CollectionDisplay;
/** The default permission for workspace users. */
@Field
@observable
+2
View File
@@ -68,11 +68,13 @@ class Comment extends Model {
* The user who resolved this comment, if it has been resolved.
*/
@Relation(() => User)
@observable
resolvedBy: User | null;
/**
* The ID of the user who resolved this comment, if it has been resolved.
*/
@observable
resolvedById: string | null;
/**
+2 -2
View File
@@ -188,10 +188,10 @@ export default class Document extends ArchivableModel implements Searchable {
@observable
collaboratorIds: string[];
@observable
@Relation(() => User)
createdBy: User | undefined;
@observable
@Relation(() => User)
updatedBy: User | undefined;
@observable
+7
View File
@@ -290,6 +290,7 @@ const CollectionScene = observer(function _CollectionScene() {
>
<PaginatedDocumentList
key="alphabetical"
display={collection.display}
documents={documents.alphabeticalInCollection(
collection.id
)}
@@ -304,6 +305,7 @@ const CollectionScene = observer(function _CollectionScene() {
>
<PaginatedDocumentList
key="old"
display={collection.display}
documents={documents.leastRecentlyUpdatedInCollection(
collection.id
)}
@@ -321,6 +323,7 @@ const CollectionScene = observer(function _CollectionScene() {
>
<PaginatedDocumentList
key="published"
display={collection.display}
documents={documents.recentlyPublishedInCollection(
collection.id
)}
@@ -339,6 +342,7 @@ const CollectionScene = observer(function _CollectionScene() {
>
<PaginatedDocumentList
key="updated"
display={collection.display}
documents={documents.recentlyUpdatedInCollection(
collection.id
)}
@@ -356,6 +360,8 @@ const CollectionScene = observer(function _CollectionScene() {
exact
>
<PaginatedDocumentList
key="recent"
display={collection.display}
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
options={{
@@ -374,6 +380,7 @@ const CollectionScene = observer(function _CollectionScene() {
exact
>
<PaginatedDocumentList
display={collection.display}
documents={documents.archivedInCollection(collection.id)}
fetch={documents.fetchPage}
options={{
@@ -19,7 +19,7 @@ export function DocumentFilter(props: Props) {
<div>
<Tooltip content={t("Remove document filter")}>
<StyledButton onClick={props.onClick} icon={<CloseIcon />} neutral>
{props.document.title}
{props.document.titleWithDefault}
</StyledButton>
</Tooltip>
</div>
@@ -0,0 +1,19 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn("collections", "display", {
type: Sequelize.STRING,
defaultValue: "list",
}, { transaction });
});
},
async down(queryInterface) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeColumn("collections", "display", { transaction });
});
},
};
+9 -1
View File
@@ -35,7 +35,11 @@ import {
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import type { CollectionSort, ProsemirrorData } from "@shared/types";
import { CollectionPermission, NavigationNode } from "@shared/types";
import {
CollectionDisplay,
CollectionPermission,
NavigationNode,
} from "@shared/types";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { sortNavigationNodes } from "@shared/utils/collections";
import slugify from "@shared/utils/slugify";
@@ -210,6 +214,10 @@ class Collection extends ParanoidModel<
@Column
icon: string | null;
/** The display mode of the collection index. */
@Column(DataType.STRING)
display: CollectionDisplay;
/** The color of the icon. */
@IsHexColor
@Column
+1
View File
@@ -20,6 +20,7 @@ export default async function presentCollection(
icon: collection.icon,
index: collection.index,
color: collection.color,
display: collection.display,
permission: collection.permission,
sharing: collection.sharing,
createdAt: collection.createdAt,
+17 -2
View File
@@ -55,8 +55,17 @@ router.post(
transaction(),
async (ctx: APIContext<T.CollectionsCreateReq>) => {
const { transaction } = ctx.state;
const { name, color, description, data, permission, sharing, icon, sort } =
ctx.input.body;
const {
name,
color,
description,
data,
display,
permission,
sharing,
icon,
sort,
} = ctx.input.body;
let { index } = ctx.input.body;
const { user } = ctx.state.auth;
@@ -80,6 +89,7 @@ router.post(
color,
teamId: user.teamId,
createdById: user.id,
display,
permission,
sharing,
sort,
@@ -570,6 +580,7 @@ router.post(
name,
description,
data,
display,
icon,
permission,
color,
@@ -623,6 +634,10 @@ router.post(
collection.description = DocumentHelper.toMarkdown(collection);
}
if (display !== undefined) {
collection.display = display;
}
if (icon !== undefined) {
collection.icon = icon;
}
+3
View File
@@ -1,6 +1,7 @@
import isUndefined from "lodash/isUndefined";
import { z } from "zod";
import {
CollectionDisplay,
CollectionPermission,
CollectionStatusFilter,
FileOperationFormat,
@@ -24,6 +25,7 @@ export const CollectionsCreateSchema = BaseSchema.extend({
.nullish(),
description: z.string().nullish(),
data: ProsemirrorSchema({ allowEmpty: true }).nullish(),
display: z.nativeEnum(CollectionDisplay).optional(),
permission: z
.nativeEnum(CollectionPermission)
.nullish()
@@ -159,6 +161,7 @@ export const CollectionsUpdateSchema = BaseSchema.extend({
data: ProsemirrorSchema({ allowEmpty: true }).nullish(),
icon: zodIconType().nullish(),
permission: z.nativeEnum(CollectionPermission).nullish(),
display: z.nativeEnum(CollectionDisplay).optional(),
color: z
.string()
.regex(ValidateColor.regex, { message: ValidateColor.message })
+11 -7
View File
@@ -155,7 +155,17 @@
"Viewers": "Viewers",
"Collections are used to group documents and choose permissions": "Collections are used to group documents and choose permissions",
"Name": "Name",
"Permission": "Permission",
"View only": "View only",
"Can edit": "Can edit",
"No access": "No access",
"Default access": "Default access",
"The default access for workspace members, you can share with more users or groups later.": "The default access for workspace members, you can share with more users or groups later.",
"Display": "Display",
"List": "List",
"Show only titles": "Show only titles",
"Post": "Post",
"Show document preview": "Show document preview",
"Public document sharing": "Public document sharing",
"Allow documents within this collection to be shared publicly on the internet.": "Allow documents within this collection to be shared publicly on the internet.",
"Saving": "Saving",
@@ -227,6 +237,7 @@
"in": "in",
"nested document": "nested document",
"nested document_plural": "nested documents",
"By {{ author }}": "By {{ author }}",
"{{ total }} task": "{{ total }} task",
"{{ total }} task_plural": "{{ total }} tasks",
"{{ completed }} task done": "{{ completed }} task done",
@@ -291,11 +302,6 @@
"Select a color": "Select a color",
"Loading": "Loading",
"Search": "Search",
"Permission": "Permission",
"View only": "View only",
"Can edit": "Can edit",
"No access": "No access",
"Default access": "Default access",
"Change Language": "Change Language",
"Dismiss": "Dismiss",
"Youre offline.": "Youre offline.",
@@ -610,7 +616,6 @@
"Add a comment": "Add a comment",
"Add a reply": "Add a reply",
"Reply": "Reply",
"Post": "Post",
"Cancel": "Cancel",
"Upload image": "Upload image",
"No resolved comments": "No resolved comments",
@@ -911,7 +916,6 @@
"Unable to upload new logo": "Unable to upload new logo",
"Delete workspace": "Delete workspace",
"These settings affect the way that your workspace appears to everyone on the team.": "These settings affect the way that your workspace appears to everyone on the team.",
"Display": "Display",
"The logo is displayed at the top left of the application.": "The logo is displayed at the top left of the application.",
"The workspace name, usually the same as your company name.": "The workspace name, usually the same as your company name.",
"Theme": "Theme",
+5
View File
@@ -1,3 +1,8 @@
export enum CollectionDisplay {
List = "list",
Post = "post",
}
export enum UserRole {
Admin = "admin",
Member = "member",