(frontend) create and open Docs documents from the explorer

Docs documents live in the tree as pointer items, so the explorer needs
to treat them specially: a dedicated create menu entry posts an item
carrying the docs metadata (same UX as template files, no auto-open),
the grid shows the Docs icon, and clicking one opens the Docs frontend
instead of the preview. The file preview route hands over to Docs too,
so shared links to the item keep working.
This commit is contained in:
Nathan Vasse
2026-07-29 12:03:11 +02:00
parent 8c954c016f
commit 00a969798b
9 changed files with 88 additions and 12 deletions
@@ -175,8 +175,9 @@ export abstract class Driver {
}): { promise: Promise<Item>; abort: () => Promise<void> };
abstract createFileFromTemplate(data: {
parentId: string;
extension: string;
extension?: string;
title: string;
metadata?: { external_app?: string };
}): Promise<Item>;
abstract duplicateItem(id: string): Promise<Item>;
abstract deleteItems(ids: string[]): Promise<void>;
@@ -450,8 +450,9 @@ export class StandardDriver extends Driver {
async createFileFromTemplate(data: {
parentId?: string;
extension: string;
extension?: string;
title: string;
metadata?: { external_app?: string };
}): Promise<Item> {
const url = data.parentId ? `items/${data.parentId}/children/` : `items/`;
@@ -461,7 +462,8 @@ export class StandardDriver extends Driver {
method: "POST",
body: JSON.stringify({
type: "file",
extension: data.extension,
...(data.extension ? { extension: data.extension } : {}),
...(data.metadata ? { metadata: data.metadata } : {}),
title: data.title,
}),
},
@@ -84,6 +84,9 @@ export type Item = {
url_preview?: string;
size?: number;
mimetype?: string;
metadata?: {
external_app?: string;
};
user_roles?: Role[];
user_role?: Role;
link_reach?: LinkReach;
@@ -237,6 +240,7 @@ export type ApiConfig = {
FRONTEND_STORAGE_GAUGE_INFORMATION_LINK?: string;
FRONTEND_CSS_URL?: string;
FRONTEND_JS_URL?: string;
FRONTEND_DOCS_URL?: string;
theme_customization?: ThemeCustomization;
};
@@ -20,6 +20,7 @@ import { openWopiInNewTab } from "@/features/wopi/openWopi";
import { itemToPreviewFile } from "@/features/explorer/utils/utils";
import { useModal } from "@gouvfr-lasuite/cunningham-react";
import { ConvertLegacyFileModal } from "@/features/explorer/components/modals/ConvertLegacyFileModal";
import { useConfig } from "@/features/config/ConfigProvider";
/**
* Wrapper around EmbeddedExplorerGrid to display a list of items in a table.
@@ -33,6 +34,7 @@ import { ConvertLegacyFileModal } from "@/features/explorer/components/modals/Co
export const AppExplorerGrid = () => {
const { t } = useTranslation();
const appExplorer = useAppExplorer();
const { config } = useConfig();
const router = useRouter();
@@ -53,6 +55,18 @@ export const AppExplorerGrid = () => {
const handleFileClick =
appExplorer.onFileClick ??
((item: Item) => {
if (item.metadata?.external_app === "docs") {
if (config.FRONTEND_DOCS_URL) {
window.open(
`${config.FRONTEND_DOCS_URL}/docs/${item.id}/`,
"_blank",
"noopener",
);
} else {
addToast(<ToasterItem>{t("explorer.grid.no_url")}</ToasterItem>);
}
return;
}
if (item.abilities.convert) {
setItemToConvert(item);
convertModal.open();
@@ -26,7 +26,7 @@ export const ItemIcon = ({
if (extendedIcon) {
return <FileIconContent icon={extendedIcon} size={size} />;
}
return <FileIcon file={itemToPreviewFile(item)} size={size} />;
return <FileIcon file={itemToPreviewFile(item)} size={size} type={type} />;
};
/**
@@ -45,6 +45,10 @@ export const getItemExtendedIcon = (
return folderIcon.src;
}
if (item.metadata?.external_app === "docs") {
return ICONS[type][MimeCategory.DOCS];
}
const uploadState = item.upload_state;
if (uploadState === ItemUploadState.SUSPICIOUS) {
return ICONS[type][MimeCategory.SUSPICIOUS];
@@ -19,6 +19,7 @@ export enum ExplorerCreateFileType {
DOC = "doc",
POWERPOINT = "powerpoint",
CALC = "calc",
DOCS_DOCUMENT = "docs",
}
const getExtension = (type: ExplorerCreateFileType) => {
@@ -29,6 +30,9 @@ const getExtension = (type: ExplorerCreateFileType) => {
return "odp";
case ExplorerCreateFileType.CALC:
return "ods";
case ExplorerCreateFileType.DOCS_DOCUMENT:
// Docs documents are external items: no template file, no extension.
return undefined;
}
};
@@ -53,6 +57,9 @@ export const ExplorerCreateFileModal = (
parentId: props.parentId,
extension: extension,
title: data.filename,
...(props.type === ExplorerCreateFileType.DOCS_DOCUMENT
? { metadata: { external_app: "docs" } }
: {}),
},
{
onSuccess: (createdItem) => {
@@ -1,4 +1,10 @@
import { MenuItem, IconSize } from "@gouvfr-lasuite/ui-kit";
import {
MenuItem,
IconSize,
FileIconContent,
ICONS,
MimeCategory,
} from "@gouvfr-lasuite/ui-kit";
import { useTranslation } from "react-i18next";
import { useGlobalExplorer } from "@/features/explorer/components/GlobalExplorerContext";
import createFolderSvg from "@/assets/icons/create_folder.svg";
@@ -89,6 +95,18 @@ export const useCreateMenuItems = ({
if (includeCreate) {
items.push(
{
icon: (
<FileIconContent
icon={ICONS.mini[MimeCategory.DOCS]}
size={IconSize.MEDIUM}
/>
),
label: t("explorer.tree.create.file.docs_document"),
callback: () =>
openCreateFileModal(ExplorerCreateFileType.DOCS_DOCUMENT),
},
{ type: "separator" },
{
icon: renderFileIcon({
type: ItemType.FILE,
@@ -362,7 +362,8 @@
"file": {
"doc": "New text document",
"powerpoint": "New slides",
"calc": "New spreadsheet"
"calc": "New spreadsheet",
"docs_document": "New Docs document"
}
},
"import": {
@@ -504,7 +505,8 @@
"label": "File name",
"placeholder": "Enter file name",
"cancel": "Cancel",
"submit": "Create"
"submit": "Create",
"title_docs": "New Docs document"
}
},
"upload": {
@@ -1084,7 +1086,8 @@
"file": {
"doc": "Nouveau document texte",
"powerpoint": "Nouvelles diapositives",
"calc": "Nouveau tableau de calcul"
"calc": "Nouveau tableau de calcul",
"docs_document": "Nouveau Docs"
},
"workspace": "Nouvel espace"
},
@@ -1213,7 +1216,8 @@
"label": "Nom du fichier",
"placeholder": "Entrez le nom du fichier",
"cancel": "Annuler",
"submit": "Créer"
"submit": "Créer",
"title_docs": "Nouveau Docs"
}
},
"upload": {
@@ -1779,7 +1783,8 @@
"file": {
"doc": "Nieuw tekstdocument",
"powerpoint": "Nieuwe presentatie",
"calc": "Nieuwe spreadsheet"
"calc": "Nieuwe spreadsheet",
"docs_document": "Nieuw Docs"
},
"workspace": "Nieuwe werkruimte"
},
@@ -1922,7 +1927,8 @@
"label": "Bestandsnaam",
"placeholder": "Voer bestandsnaam in",
"cancel": "Annuleren",
"submit": "Maken"
"submit": "Maken",
"title_docs": "Nieuw Docs-document"
}
},
"upload": {
@@ -10,18 +10,38 @@ import { useRouter } from "next/router";
import { useTranslation } from "react-i18next";
import { useItem } from "@/features/explorer/hooks/useQueries";
import { GlobalLayout } from "@/features/layouts/components/global/GlobalLayout";
import { useConfig } from "@/features/config/ConfigProvider";
import { useEffect } from "react";
export default function FilePage() {
const { t } = useTranslation();
const router = useRouter();
const itemId = router.query.id as string;
const { config } = useConfig();
const { data: item, isLoading, error } = useItem(itemId);
// Items pointing to an external app are not previewable here: hand the
// user over to the owning application.
const externalAppUrl =
item?.metadata?.external_app === "docs" && config?.FRONTEND_DOCS_URL
? `${config.FRONTEND_DOCS_URL}/docs/${item.id}/`
: null;
useEffect(() => {
if (externalAppUrl) {
window.location.replace(externalAppUrl);
}
}, [externalAppUrl]);
// On 403, 401, the user is automatically redirected to the 401/403 page.
// If the error is a 401 or 403, we want to show the spinner page because an auto redirect is happening.
if (isLoading || (error && [401, 403].includes(error.code))) {
if (
isLoading ||
externalAppUrl ||
(error && [401, 403].includes(error.code))
) {
return <SpinnerPage />;
}