feat: add the ability to choose default collection (#3029)
Co-authored-by: Tom Moor <tom@getoutline.com> Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
103
app/components/DefaultCollectionInputSelect.tsx
Normal file
103
app/components/DefaultCollectionInputSelect.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { HomeIcon } from "outline-icons";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CollectionIcon from "~/components/CollectionIcon";
|
||||
import Flex from "~/components/Flex";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type DefaultCollectionInputSelectProps = {
|
||||
onSelectCollection: (collection: string) => void;
|
||||
defaultCollectionId: string | null;
|
||||
};
|
||||
|
||||
const DefaultCollectionInputSelect = ({
|
||||
onSelectCollection,
|
||||
defaultCollectionId,
|
||||
}: DefaultCollectionInputSelectProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { collections } = useStores();
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [fetchError, setFetchError] = useState();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function load() {
|
||||
if (!collections.isLoaded && !fetching && !fetchError) {
|
||||
try {
|
||||
setFetching(true);
|
||||
await collections.fetchPage({
|
||||
limit: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
showToast(
|
||||
t("Collections could not be loaded, please reload the app"),
|
||||
{
|
||||
type: "error",
|
||||
}
|
||||
);
|
||||
setFetchError(error);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [showToast, fetchError, t, fetching, collections]);
|
||||
|
||||
const options = React.useMemo(
|
||||
() =>
|
||||
collections.publicCollections.reduce(
|
||||
(acc, collection) => [
|
||||
...acc,
|
||||
{
|
||||
label: (
|
||||
<Flex align="center">
|
||||
<IconWrapper>
|
||||
<CollectionIcon collection={collection} />
|
||||
</IconWrapper>
|
||||
{collection.name}
|
||||
</Flex>
|
||||
),
|
||||
value: collection.id,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: (
|
||||
<Flex align="center">
|
||||
<IconWrapper>
|
||||
<HomeIcon color="currentColor" />
|
||||
</IconWrapper>
|
||||
{t("Home")}
|
||||
</Flex>
|
||||
),
|
||||
value: "home",
|
||||
},
|
||||
]
|
||||
),
|
||||
[collections.publicCollections, t]
|
||||
);
|
||||
|
||||
if (fetching) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<InputSelect
|
||||
value={defaultCollectionId ?? "home"}
|
||||
label={t("Start view")}
|
||||
options={options}
|
||||
onChange={onSelectCollection}
|
||||
ariaLabel={t("Default collection")}
|
||||
note={t(
|
||||
"This is the screen that team members will first see when they sign in."
|
||||
)}
|
||||
short
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultCollectionInputSelect;
|
||||
@@ -18,12 +18,12 @@ import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
|
||||
import { LabelText } from "./Input";
|
||||
|
||||
export type Option = {
|
||||
label: string;
|
||||
label: string | JSX.Element;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type Props = {
|
||||
value?: string;
|
||||
value?: string | null;
|
||||
label?: string;
|
||||
nude?: boolean;
|
||||
ariaLabel: string;
|
||||
@@ -37,16 +37,13 @@ export type Props = {
|
||||
onChange: (value: string | null) => void;
|
||||
};
|
||||
|
||||
const getOptionFromValue = (
|
||||
options: Option[],
|
||||
value: string | undefined | null
|
||||
) => {
|
||||
const getOptionFromValue = (options: Option[], value: string | null) => {
|
||||
return options.find((option) => option.value === value);
|
||||
};
|
||||
|
||||
const InputSelect = (props: Props) => {
|
||||
const {
|
||||
value,
|
||||
value = null,
|
||||
label,
|
||||
className,
|
||||
labelHidden,
|
||||
@@ -72,7 +69,7 @@ const InputSelect = (props: Props) => {
|
||||
disabled,
|
||||
});
|
||||
|
||||
const previousValue = React.useRef<string | undefined | null>(value);
|
||||
const previousValue = React.useRef<string | null>(value);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const selectedRef = React.useRef<HTMLDivElement>(null);
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
@@ -82,6 +79,10 @@ const InputSelect = (props: Props) => {
|
||||
select.visible,
|
||||
select.unstable_disclosureRef
|
||||
);
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
const selectedValueIndex = options.findIndex(
|
||||
(option) => option.value === select.selectedValue
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (previousValue.current === select.selectedValue) {
|
||||
@@ -95,10 +96,6 @@ const InputSelect = (props: Props) => {
|
||||
|
||||
load();
|
||||
}, [onChange, select.selectedValue]);
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
const selectedValueIndex = options.findIndex(
|
||||
(option) => option.value === select.selectedValue
|
||||
);
|
||||
|
||||
// Ensure selected option is visible when opening the input
|
||||
React.useEffect(() => {
|
||||
@@ -182,30 +179,23 @@ const InputSelect = (props: Props) => {
|
||||
}
|
||||
>
|
||||
{select.visible
|
||||
? options.map((option) => (
|
||||
<StyledSelectOption
|
||||
{...select}
|
||||
value={option.value}
|
||||
key={option.value}
|
||||
ref={
|
||||
select.selectedValue === option.value
|
||||
? selectedRef
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{select.selectedValue !== undefined && (
|
||||
<>
|
||||
{select.selectedValue === option.value ? (
|
||||
<CheckmarkIcon color="currentColor" />
|
||||
) : (
|
||||
<Spacer />
|
||||
)}
|
||||
|
||||
</>
|
||||
)}
|
||||
{option.label}
|
||||
</StyledSelectOption>
|
||||
))
|
||||
? options.map((option) => {
|
||||
const isSelected =
|
||||
select.selectedValue === option.value;
|
||||
const Icon = isSelected ? CheckmarkIcon : Spacer;
|
||||
return (
|
||||
<StyledSelectOption
|
||||
{...select}
|
||||
value={option.value}
|
||||
key={option.value}
|
||||
ref={isSelected ? selectedRef : undefined}
|
||||
>
|
||||
<Icon />
|
||||
|
||||
{option.label}
|
||||
</StyledSelectOption>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</Background>
|
||||
</Positioner>
|
||||
@@ -261,6 +251,10 @@ const StyledButton = styled(Button)<{ nude?: boolean }>`
|
||||
|
||||
export const StyledSelectOption = styled(SelectOption)`
|
||||
${MenuAnchorCSS}
|
||||
/* overriding the styles from MenuAnchorCSS because we use here */
|
||||
svg:not(:last-child) {
|
||||
margin-right: 0px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Wrapper = styled.label<{ short?: boolean }>`
|
||||
|
||||
@@ -99,7 +99,7 @@ function SidebarLink(
|
||||
}
|
||||
|
||||
// accounts for whitespace around icon
|
||||
const IconWrapper = styled.span`
|
||||
export const IconWrapper = styled.span`
|
||||
margin-left: -4px;
|
||||
margin-right: 4px;
|
||||
height: 24px;
|
||||
|
||||
@@ -188,11 +188,9 @@ class SocketProvider extends React.Component<Props> {
|
||||
if (event.collectionIds) {
|
||||
for (const collectionDescriptor of event.collectionIds) {
|
||||
const collectionId = collectionDescriptor.id;
|
||||
const collection = collections.get(collectionId) || {};
|
||||
const collection = collections.get(collectionId);
|
||||
|
||||
if (event.event === "collections.delete") {
|
||||
const collection = collections.get(collectionId);
|
||||
|
||||
if (collection) {
|
||||
collection.deletedAt = collectionDescriptor.updatedAt;
|
||||
}
|
||||
@@ -211,10 +209,11 @@ class SocketProvider extends React.Component<Props> {
|
||||
|
||||
// if we already have the latest version (it was us that performed
|
||||
// the change) then we don't need to update anything either.
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedAt' does not exist on type '{}'.
|
||||
const { updatedAt } = collection;
|
||||
|
||||
if (updatedAt === collectionDescriptor.updatedAt) {
|
||||
if (
|
||||
collection &&
|
||||
collection.updatedAt === collectionDescriptor.updatedAt
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ class Team extends BaseModel {
|
||||
@observable
|
||||
documentEmbeds: boolean;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
defaultCollectionId: string | null;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
guestSignin: boolean;
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function AuthenticatedRoutes() {
|
||||
<Route path="/404" component={Error404} />
|
||||
<SettingsRoutes />
|
||||
<Route component={NotFound} />
|
||||
</Switch>{" "}
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
</Layout>
|
||||
</SocketProvider>
|
||||
|
||||
@@ -6,6 +6,7 @@ import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import HelpText from "~/components/HelpText";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { homePath } from "~/utils/routeHelpers";
|
||||
|
||||
@@ -16,6 +17,7 @@ type Props = {
|
||||
|
||||
function CollectionDelete({ collection, onSubmit }: Props) {
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const team = useCurrentTeam();
|
||||
const { showToast } = useToasts();
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
@@ -26,8 +28,8 @@ function CollectionDelete({ collection, onSubmit }: Props) {
|
||||
|
||||
try {
|
||||
await collection.delete();
|
||||
history.push(homePath());
|
||||
onSubmit();
|
||||
history.push(homePath());
|
||||
} catch (err) {
|
||||
showToast(err.message, {
|
||||
type: "error",
|
||||
@@ -36,7 +38,7 @@ function CollectionDelete({ collection, onSubmit }: Props) {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
},
|
||||
[showToast, onSubmit, collection, history]
|
||||
[collection, history, onSubmit, showToast]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -53,6 +55,19 @@ function CollectionDelete({ collection, onSubmit }: Props) {
|
||||
}}
|
||||
/>
|
||||
</HelpText>
|
||||
{team.defaultCollectionId === collection.id ? (
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="Also, <em>{{collectionName}}</em> is being used as the start view – deleting it will reset the start view to the Home page."
|
||||
values={{
|
||||
collectionName: collection.name,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</HelpText>
|
||||
) : null}
|
||||
<Button type="submit" disabled={isDeleting} autoFocus danger>
|
||||
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||
</Button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import { useLocation, Link, Redirect } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { setCookie } from "tiny-cookie";
|
||||
import { Config } from "~/stores/AuthStore";
|
||||
import ButtonLarge from "~/components/ButtonLarge";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -22,8 +23,7 @@ import { changeLanguage, detectLanguage } from "~/utils/language";
|
||||
import Notices from "./Notices";
|
||||
import Provider from "./Provider";
|
||||
|
||||
// @ts-expect-error ts-migrate(7031) FIXME: Binding element 'config' implicitly has an 'any' t... Remove this comment to see the full error message
|
||||
function Header({ config }) {
|
||||
function Header({ config }: { config: Config }) {
|
||||
const { t } = useTranslation();
|
||||
const isHosted = env.DEPLOYMENT === "hosted";
|
||||
const isSubdomain = !!config.hostname;
|
||||
@@ -84,6 +84,10 @@ function Login() {
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
if (auth.authenticated && auth.team?.defaultCollectionId) {
|
||||
return <Redirect to={`/collection/${auth.team?.defaultCollectionId}`} />;
|
||||
}
|
||||
|
||||
if (auth.authenticated) {
|
||||
return <Redirect to="/home" />;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRef, useState } from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import Button from "~/components/Button";
|
||||
import DefaultCollectionInputSelect from "~/components/DefaultCollectionInputSelect";
|
||||
import Heading from "~/components/Heading";
|
||||
import HelpText from "~/components/HelpText";
|
||||
import Input from "~/components/Input";
|
||||
@@ -23,6 +24,9 @@ function Details() {
|
||||
const [name, setName] = useState(team.name);
|
||||
const [subdomain, setSubdomain] = useState(team.subdomain);
|
||||
const [avatarUrl, setAvatarUrl] = useState<string>(team.avatarUrl);
|
||||
const [defaultCollectionId, setDefaultCollectionId] = useState<string | null>(
|
||||
team.defaultCollectionId
|
||||
);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (event?: React.SyntheticEvent) => {
|
||||
@@ -35,6 +39,7 @@ function Details() {
|
||||
name,
|
||||
avatarUrl,
|
||||
subdomain,
|
||||
defaultCollectionId,
|
||||
});
|
||||
showToast(t("Settings saved"), {
|
||||
type: "success",
|
||||
@@ -45,7 +50,7 @@ function Details() {
|
||||
});
|
||||
}
|
||||
},
|
||||
[auth, showToast, name, avatarUrl, subdomain, t]
|
||||
[auth, name, avatarUrl, subdomain, defaultCollectionId, showToast, t]
|
||||
);
|
||||
|
||||
const handleNameChange = React.useCallback(
|
||||
@@ -79,7 +84,13 @@ function Details() {
|
||||
[showToast, t]
|
||||
);
|
||||
|
||||
const onSelectCollection = React.useCallback(async (value: string) => {
|
||||
const defaultCollectionId = value === "home" ? null : value;
|
||||
setDefaultCollectionId(defaultCollectionId);
|
||||
}, []);
|
||||
|
||||
const isValid = form.current && form.current.checkValidity();
|
||||
|
||||
return (
|
||||
<Scene title={t("Details")} icon={<TeamIcon color="currentColor" />}>
|
||||
<Heading>{t("Details")}</Heading>
|
||||
@@ -128,6 +139,10 @@ function Details() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DefaultCollectionInputSelect
|
||||
onSelectCollection={onSelectCollection}
|
||||
defaultCollectionId={defaultCollectionId}
|
||||
/>
|
||||
<Button type="submit" disabled={auth.isSaving || !isValid}>
|
||||
{auth.isSaving ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
|
||||
@@ -25,7 +25,7 @@ type Provider = {
|
||||
authUrl: string;
|
||||
};
|
||||
|
||||
type Config = {
|
||||
export type Config = {
|
||||
name?: string;
|
||||
hostname?: string;
|
||||
providers: Provider[];
|
||||
@@ -228,6 +228,7 @@ export default class AuthStore {
|
||||
avatarUrl?: string | null | undefined;
|
||||
sharing?: boolean;
|
||||
collaborativeEditing?: boolean;
|
||||
defaultCollectionId?: string | null;
|
||||
subdomain?: string | null | undefined;
|
||||
}) => {
|
||||
this.isSaving = true;
|
||||
|
||||
@@ -139,7 +139,10 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
||||
}
|
||||
|
||||
@action
|
||||
async fetch(id: string, options: Record<string, any> = {}): Promise<any> {
|
||||
async fetch(
|
||||
id: string,
|
||||
options: Record<string, any> = {}
|
||||
): Promise<Collection> {
|
||||
const item = this.get(id) || this.getByUrl(id);
|
||||
if (item && !options.force) {
|
||||
return item;
|
||||
@@ -164,6 +167,13 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
||||
}
|
||||
}
|
||||
|
||||
@computed
|
||||
get publicCollections() {
|
||||
return this.orderedData.filter((collection) =>
|
||||
["read", "read_write"].includes(collection.permission || "")
|
||||
);
|
||||
}
|
||||
|
||||
getPathForDocument(documentId: string): DocumentPath | undefined {
|
||||
return this.pathsToDocuments.find((path) => path.id === documentId);
|
||||
}
|
||||
|
||||
88
server/commands/teamUpdater.ts
Normal file
88
server/commands/teamUpdater.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import { Event, Team, User } from "@server/models";
|
||||
|
||||
type TeamUpdaterProps = {
|
||||
params: Partial<Team>;
|
||||
ip?: string;
|
||||
user: User;
|
||||
team: Team;
|
||||
};
|
||||
|
||||
const teamUpdater = async ({ params, user, team, ip }: TeamUpdaterProps) => {
|
||||
const {
|
||||
name,
|
||||
avatarUrl,
|
||||
subdomain,
|
||||
sharing,
|
||||
guestSignin,
|
||||
documentEmbeds,
|
||||
collaborativeEditing,
|
||||
defaultCollectionId,
|
||||
defaultUserRole,
|
||||
} = params;
|
||||
|
||||
if (subdomain !== undefined && process.env.SUBDOMAINS_ENABLED === "true") {
|
||||
team.subdomain = subdomain === "" ? null : subdomain;
|
||||
}
|
||||
|
||||
if (name) {
|
||||
team.name = name;
|
||||
}
|
||||
if (sharing !== undefined) {
|
||||
team.sharing = sharing;
|
||||
}
|
||||
if (documentEmbeds !== undefined) {
|
||||
team.documentEmbeds = documentEmbeds;
|
||||
}
|
||||
if (guestSignin !== undefined) {
|
||||
team.guestSignin = guestSignin;
|
||||
}
|
||||
if (avatarUrl !== undefined) {
|
||||
team.avatarUrl = avatarUrl;
|
||||
}
|
||||
if (defaultCollectionId !== undefined) {
|
||||
team.defaultCollectionId = defaultCollectionId;
|
||||
}
|
||||
if (collaborativeEditing !== undefined) {
|
||||
team.collaborativeEditing = collaborativeEditing;
|
||||
}
|
||||
if (defaultUserRole !== undefined) {
|
||||
team.defaultUserRole = defaultUserRole;
|
||||
}
|
||||
|
||||
const changes = team.changed();
|
||||
|
||||
const transaction: Transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const savedTeam = await team.save({
|
||||
transaction,
|
||||
});
|
||||
if (changes) {
|
||||
const data = changes.reduce((acc, curr) => {
|
||||
return { ...acc, [curr]: team[curr] };
|
||||
}, {});
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "teams.update",
|
||||
actorId: user.id,
|
||||
teamId: user.teamId,
|
||||
data,
|
||||
ip: ip,
|
||||
},
|
||||
{
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
}
|
||||
await transaction.commit();
|
||||
return savedTeam;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default teamUpdater;
|
||||
15
server/migrations/20220129092607-add-defaultCollectionId.js
Normal file
15
server/migrations/20220129092607-add-defaultCollectionId.js
Normal file
@@ -0,0 +1,15 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn("teams", "defaultCollectionId", {
|
||||
type: Sequelize.UUID,
|
||||
defaultValue: null,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn("teams", "defaultCollectionId");
|
||||
}
|
||||
};
|
||||
@@ -66,6 +66,9 @@ class Team extends ParanoidModel {
|
||||
@Column
|
||||
domain: string | null;
|
||||
|
||||
@Column(DataType.UUID)
|
||||
defaultCollectionId: string | null;
|
||||
|
||||
@Column
|
||||
avatarUrl: string | null;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export default function present(team: Team) {
|
||||
avatarUrl: team.logoUrl,
|
||||
sharing: team.sharing,
|
||||
collaborativeEditing: team.collaborativeEditing,
|
||||
defaultCollectionId: team.defaultCollectionId,
|
||||
documentEmbeds: team.documentEmbeds,
|
||||
guestSignin: team.guestSignin,
|
||||
subdomain: team.subdomain,
|
||||
|
||||
@@ -3,6 +3,7 @@ import invariant from "invariant";
|
||||
import Router from "koa-router";
|
||||
import { Sequelize, Op, WhereOptions } from "sequelize";
|
||||
import collectionExporter from "@server/commands/collectionExporter";
|
||||
import teamUpdater from "@server/commands/teamUpdater";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import {
|
||||
@@ -601,6 +602,19 @@ router.post("collections.update", auth(), async (ctx) => {
|
||||
// if the privacy level has changed. Otherwise skip this query for speed.
|
||||
if (privacyChanged || sharingChanged) {
|
||||
await collection.reload();
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
|
||||
if (
|
||||
collection.permission === null &&
|
||||
team?.defaultCollectionId === collection.id
|
||||
) {
|
||||
await teamUpdater({
|
||||
params: { defaultCollectionId: null },
|
||||
ip: ctx.request.ip,
|
||||
user,
|
||||
team,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
@@ -612,17 +626,19 @@ router.post("collections.update", auth(), async (ctx) => {
|
||||
router.post("collections.list", auth(), pagination(), async (ctx) => {
|
||||
const { user } = ctx.state;
|
||||
const collectionIds = await user.collectionIds();
|
||||
const where: WhereOptions<Collection> = {
|
||||
teamId: user.teamId,
|
||||
id: collectionIds,
|
||||
};
|
||||
const collections = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
id: collectionIds,
|
||||
},
|
||||
where,
|
||||
order: [["updatedAt", "DESC"]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
|
||||
const nullIndex = collections.findIndex(
|
||||
(collection) => collection.index === null
|
||||
);
|
||||
@@ -649,6 +665,8 @@ router.post("collections.delete", auth(), async (ctx) => {
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(id);
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
|
||||
authorize(user, "delete", collection);
|
||||
|
||||
const total = await Collection.count();
|
||||
@@ -657,6 +675,16 @@ router.post("collections.delete", auth(), async (ctx) => {
|
||||
}
|
||||
|
||||
await collection.destroy();
|
||||
|
||||
if (team && team.defaultCollectionId === collection.id) {
|
||||
await teamUpdater({
|
||||
params: { defaultCollectionId: null },
|
||||
ip: ctx.request.ip,
|
||||
user,
|
||||
team,
|
||||
});
|
||||
}
|
||||
|
||||
await Event.create({
|
||||
name: "collections.delete",
|
||||
collectionId: collection.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import TestServer from "fetch-test-server";
|
||||
import webService from "@server/services/web";
|
||||
import { buildAdmin, buildCollection, buildTeam } from "@server/test/factories";
|
||||
import { flushdb, seed } from "@server/test/support";
|
||||
|
||||
const app = webService();
|
||||
@@ -70,4 +71,109 @@ describe("#team.update", () => {
|
||||
const res = await server.post("/api/team.update");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
|
||||
it("should update default collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/team.update", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
defaultCollectionId: collection.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.defaultCollectionId).toEqual(collection.id);
|
||||
});
|
||||
|
||||
it("should default to home if default collection is deleted", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
});
|
||||
|
||||
await buildCollection({
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/team.update", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
defaultCollectionId: collection.id,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.defaultCollectionId).toEqual(collection.id);
|
||||
|
||||
const deleteRes = await server.post("/api/collections.delete", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: collection.id,
|
||||
},
|
||||
});
|
||||
expect(deleteRes.status).toEqual(200);
|
||||
|
||||
const res3 = await server.post("/api/auth.info", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
},
|
||||
});
|
||||
const body3 = await res3.json();
|
||||
expect(res3.status).toEqual(200);
|
||||
expect(body3.data.team.defaultCollectionId).toEqual(null);
|
||||
});
|
||||
|
||||
it("should update default collection to null when collection is made private", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
});
|
||||
|
||||
await buildCollection({
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/team.update", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
defaultCollectionId: collection.id,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.defaultCollectionId).toEqual(collection.id);
|
||||
|
||||
const updateRes = await server.post("/api/collections.update", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: collection.id,
|
||||
permission: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateRes.status).toEqual(200);
|
||||
|
||||
const res3 = await server.post("/api/auth.info", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
},
|
||||
});
|
||||
const body3 = await res3.json();
|
||||
expect(res3.status).toEqual(200);
|
||||
expect(body3.data.team.defaultCollectionId).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import Router from "koa-router";
|
||||
import teamUpdater from "@server/commands/teamUpdater";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { Event, Team } from "@server/models";
|
||||
import { Team } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentTeam, presentPolicies } from "@server/presenters";
|
||||
import { assertUuid } from "@server/validation";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
@@ -15,61 +17,38 @@ router.post("team.update", auth(), async (ctx) => {
|
||||
guestSignin,
|
||||
documentEmbeds,
|
||||
collaborativeEditing,
|
||||
defaultCollectionId,
|
||||
defaultUserRole,
|
||||
} = ctx.body;
|
||||
|
||||
const { user } = ctx.state;
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
authorize(user, "update", team);
|
||||
|
||||
if (subdomain !== undefined && process.env.SUBDOMAINS_ENABLED === "true") {
|
||||
team.subdomain = subdomain === "" ? null : subdomain;
|
||||
if (defaultCollectionId !== undefined && defaultCollectionId !== null) {
|
||||
assertUuid(defaultCollectionId, "defaultCollectionId must be uuid");
|
||||
}
|
||||
|
||||
if (name) {
|
||||
team.name = name;
|
||||
}
|
||||
if (sharing !== undefined) {
|
||||
team.sharing = sharing;
|
||||
}
|
||||
if (documentEmbeds !== undefined) {
|
||||
team.documentEmbeds = documentEmbeds;
|
||||
}
|
||||
if (guestSignin !== undefined) {
|
||||
team.guestSignin = guestSignin;
|
||||
}
|
||||
if (avatarUrl !== undefined) {
|
||||
team.avatarUrl = avatarUrl;
|
||||
}
|
||||
|
||||
if (collaborativeEditing !== undefined) {
|
||||
team.collaborativeEditing = collaborativeEditing;
|
||||
}
|
||||
|
||||
if (defaultUserRole !== undefined) {
|
||||
team.defaultUserRole = defaultUserRole;
|
||||
}
|
||||
|
||||
const changes = team.changed();
|
||||
const data = {};
|
||||
await team.save();
|
||||
|
||||
if (changes) {
|
||||
for (const change of changes) {
|
||||
data[change] = team[change];
|
||||
}
|
||||
|
||||
await Event.create({
|
||||
name: "teams.update",
|
||||
actorId: user.id,
|
||||
teamId: user.teamId,
|
||||
data,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
}
|
||||
const updatedTeam = await teamUpdater({
|
||||
params: {
|
||||
name,
|
||||
avatarUrl,
|
||||
subdomain,
|
||||
sharing,
|
||||
guestSignin,
|
||||
documentEmbeds,
|
||||
collaborativeEditing,
|
||||
defaultCollectionId,
|
||||
defaultUserRole,
|
||||
},
|
||||
user,
|
||||
team,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentTeam(team),
|
||||
policies: presentPolicies(user, [team]),
|
||||
data: presentTeam(updatedTeam),
|
||||
policies: presentPolicies(user, [updatedTeam]),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -42,12 +42,29 @@ router.get("/redirect", auth(), async (ctx) => {
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const defaultCollectionId = team?.defaultCollectionId;
|
||||
|
||||
if (defaultCollectionId) {
|
||||
const collection = await Collection.findOne({
|
||||
where: {
|
||||
id: defaultCollectionId,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (collection) {
|
||||
ctx.redirect(`${team.url}${collection.url}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const hasViewedDocuments = !!view;
|
||||
|
||||
ctx.redirect(
|
||||
!hasViewedDocuments && collection
|
||||
? `${team!.url}${collection.url}`
|
||||
: `${team!.url}/home`
|
||||
? `${team?.url}${collection.url}`
|
||||
: `${team?.url}/home`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -101,6 +101,23 @@ export async function signIn(
|
||||
httpOnly: false,
|
||||
expires,
|
||||
});
|
||||
|
||||
const defaultCollectionId = team.defaultCollectionId;
|
||||
|
||||
if (defaultCollectionId) {
|
||||
const collection = await Collection.findOne({
|
||||
where: {
|
||||
id: defaultCollectionId,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (collection) {
|
||||
ctx.redirect(`${team.url}${collection.url}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const [collection, view] = await Promise.all([
|
||||
Collection.findFirstCollectionForUser(user),
|
||||
View.findOne({
|
||||
|
||||
@@ -62,6 +62,10 @@
|
||||
"Server connection lost": "Server connection lost",
|
||||
"Edits you make will sync once you’re online": "Edits you make will sync once you’re online",
|
||||
"Submenu": "Submenu",
|
||||
"Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app",
|
||||
"Start view": "Start view",
|
||||
"Default collection": "Default collection",
|
||||
"This is the screen that team members will first see when they sign in.": "This is the screen that team members will first see when they sign in.",
|
||||
"Deleted Collection": "Deleted Collection",
|
||||
"Unpin": "Unpin",
|
||||
"History": "History",
|
||||
@@ -130,7 +134,6 @@
|
||||
"Back": "Back",
|
||||
"Document archived": "Document archived",
|
||||
"Move document": "Move document",
|
||||
"Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app",
|
||||
"Collections": "Collections",
|
||||
"Untitled": "Untitled",
|
||||
"New nested document": "New nested document",
|
||||
@@ -298,6 +301,7 @@
|
||||
"Create a document": "Create a document",
|
||||
"Manage permissions": "Manage permissions",
|
||||
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
|
||||
"Also, <em>{{collectionName}}</em> is being used as the start view – deleting it will reset the start view to the Home page.": "Also, <em>{{collectionName}}</em> is being used as the start view – deleting it will reset the start view to the Home page.",
|
||||
"Deleting": "Deleting",
|
||||
"I’m sure – Delete": "I’m sure – Delete",
|
||||
"The collection was updated": "The collection was updated",
|
||||
|
||||
Reference in New Issue
Block a user