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:
Saumya Pandey
2022-02-10 10:06:10 +05:30
committed by GitHub
parent 9dfd1ec2dd
commit 42061edbd1
21 changed files with 507 additions and 104 deletions

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

View File

@@ -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 />
)}
&nbsp;
</>
)}
{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 />
&nbsp;
{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 &nbsp; here */
svg:not(:last-child) {
margin-right: 0px;
}
`;
const Wrapper = styled.label<{ short?: boolean }>`

View File

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

View File

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

View File

@@ -27,6 +27,10 @@ class Team extends BaseModel {
@observable
documentEmbeds: boolean;
@Field
@observable
defaultCollectionId: string | null;
@Field
@observable
guestSignin: boolean;

View File

@@ -87,7 +87,7 @@ export default function AuthenticatedRoutes() {
<Route path="/404" component={Error404} />
<SettingsRoutes />
<Route component={NotFound} />
</Switch>{" "}
</Switch>
</React.Suspense>
</Layout>
</SocketProvider>

View File

@@ -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("Im sure Delete")}
</Button>

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -66,6 +66,9 @@ class Team extends ParanoidModel {
@Column
domain: string | null;
@Column(DataType.UUID)
defaultCollectionId: string | null;
@Column
avatarUrl: string | null;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -62,6 +62,10 @@
"Server connection lost": "Server connection lost",
"Edits you make will sync once youre online": "Edits you make will sync once youre 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",
"Im sure Delete": "Im sure Delete",
"The collection was updated": "The collection was updated",