Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions cypress/e2e/builder/item/download/export-zip.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
import {
buildDownloadButtonId,
buildExportAsZipButtonId,
buildItemCard,
buildItemsGridMoreButtonSelector,
buildPublicExportZipButtonId,
} from '../../../../../src/config/selectors';
import { HOME_PATH, buildItemPath } from '../../utils';

Expand Down Expand Up @@ -61,3 +63,61 @@ describe('Export Folder as ZIP', () => {
);
});
});

describe('Download public folder as ZIP', () => {
const publicVisibility = (item: { path: string }) => ({
id: 'public-visibility-id',
type: 'public' as const,
itemPath: item.path,
createdAt: new Date().toISOString(),
});
const buildAdminExportHref = (itemId: string) =>
`/public/folders/${itemId}/export`;

it('logged out visitor can download a public folder and its subfolder', () => {
const parent = PackedFolderItemFactory();
const parentWithVisibility = {
...parent,
public: publicVisibility(parent),
};
const child = PackedFolderItemFactory({ parentItem: parent });
const childWithVisibility = {
...child,
public: publicVisibility(parent),
};
cy.setUpApi({
items: [parentWithVisibility, childWithVisibility],
currentMember: null,
});

// subfolder card menu
cy.visit(buildItemPath(parent.id));
cy.get(buildItemsGridMoreButtonSelector(child.id)).click();
cy.get(`[role="menu"] #${buildPublicExportZipButtonId(child.id)}`)
.should('have.attr', 'href')
.and('eq', buildAdminExportHref(child.id));
cy.get('body').type('{esc}');

// folder header menu
cy.get(`[aria-label="More"]`).first().click();
cy.get(`[role="menu"] #${buildPublicExportZipButtonId(parent.id)}`)
.should('have.attr', 'href')
.and('eq', buildAdminExportHref(parent.id));
// logged out visitors do not use the emailed export
cy.get(`[role="menu"] #${buildExportAsZipButtonId(parent.id)}`).should(
'not.exist',
);
});

it('is not shown on a non-public folder', () => {
const parent = PackedFolderItemFactory();
const child = PackedFolderItemFactory({ parentItem: parent });
cy.setUpApi({ items: [parent, child], currentMember: null });
cy.visit(buildItemPath(parent.id));
// the menus are not rendered at all, so the zip button cannot be reached
cy.get(`#${buildItemCard(child.id)}`).should('exist');
cy.get(buildItemsGridMoreButtonSelector(child.id)).should('not.exist');
cy.get(`#${buildPublicExportZipButtonId(child.id)}`).should('not.exist');
cy.get(`#${buildPublicExportZipButtonId(parent.id)}`).should('not.exist');
});
});
6 changes: 5 additions & 1 deletion cypress/e2e/builder/item/pin/pinItem.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ITEM_HEADER_ID,
PIN_ITEM_BUTTON_CLASS,
buildItemsGridMoreButtonSelector,
buildPublicExportZipButtonId,
} from '../../../../../src/config/selectors';
import { HOME_PATH, buildItemPath } from '../../utils';

Expand All @@ -27,7 +28,10 @@ describe('Anonymous', () => {
});
it("Can see item but can't pin", () => {
cy.get(`#${ITEM_HEADER_ID}`).should('be.visible');
cy.get(`#${ITEM_HEADER_ID} [aria-label="More"]`).should('not.exist');
// logged out visitors can only download the zip of a public folder
cy.get(`#${ITEM_HEADER_ID} [aria-label="More"]`).click();
cy.get(`#${buildPublicExportZipButtonId(itemId)}`).should('be.visible');
cy.get(`.${PIN_ITEM_BUTTON_CLASS}`).should('not.exist');
});
});

Expand Down
2 changes: 2 additions & 0 deletions src/config/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,8 @@ export const CONFIRM_MEMBERSHIP_DELETE_BUTTON_ID =
'confirmDeleteMembershipButton';
export const buildDownloadButtonId = (itemId: string): string =>
`download-button-id-${itemId}`;
export const buildPublicExportZipButtonId = (itemId: string): string =>
`public-export-zip-button-id-${itemId}`;
export const buildExportAsZipButtonId = (itemId: string): string =>
`export-zip-button-id-${itemId}`;
export const CUSTOM_APP_URL_ID = 'customAppURLId';
Expand Down
1 change: 1 addition & 0 deletions src/locales/de/builder.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@
"ITEM_MENU_CREATE_SHORTCUT_MENU_ITEM": "Verknüpfung erstellen",
"ITEM_MENU_FLAG_MENU_ITEM": "Melden",
"ITEM_MENU_EXPORT_RAW_MENU_ITEM": "Als ZIP exportieren",
"ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM": "ZIP herunterladen",
"ITEM_METADATA_CREATED_AT_TITLE": "Hergestellt in",
"ITEM_METADATA_CREATOR_TITLE": "Urheber",
"ITEM_METADATA_LINK_TITLE": "Verknüpfung",
Expand Down
1 change: 1 addition & 0 deletions src/locales/en/builder.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@
"ITEM_MENU_CREATE_SHORTCUT_MENU_ITEM": "Create Shortcut",
"ITEM_MENU_FLAG_MENU_ITEM": "Flag",
"ITEM_MENU_EXPORT_RAW_MENU_ITEM": "Export as ZIP",
"ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM": "Download ZIP",
"ITEM_METADATA_CREATED_AT_TITLE": "Creation",
"ITEM_METADATA_CREATOR_TITLE": "Creator",
"ITEM_METADATA_LINK_TITLE": "Link",
Expand Down
1 change: 1 addition & 0 deletions src/locales/es/builder.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@
"ITEM_MENU_CREATE_SHORTCUT_MENU_ITEM": "Crear acceso directo",
"ITEM_MENU_FLAG_MENU_ITEM": "Bandera",
"ITEM_MENU_EXPORT_RAW_MENU_ITEM": "Exportar como ZIP",
"ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM": "Descargar ZIP",
"ITEM_METADATA_CREATED_AT_TITLE": "Creado en",
"ITEM_METADATA_CREATOR_TITLE": "Creador",
"ITEM_METADATA_LINK_TITLE": "Enlace",
Expand Down
1 change: 1 addition & 0 deletions src/locales/fr/builder.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@
"ITEM_MENU_CREATE_SHORTCUT_MENU_ITEM": "Créer un Raccourci",
"ITEM_MENU_FLAG_MENU_ITEM": "Signaler",
"ITEM_MENU_EXPORT_RAW_MENU_ITEM": "Exporter en ZIP",
"ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM": "Télécharger le ZIP",
"ITEM_METADATA_CREATED_AT_TITLE": "Créé à",
"ITEM_METADATA_CREATOR_TITLE": "Créateur",
"ITEM_METADATA_LINK_TITLE": "Lien",
Expand Down
1 change: 1 addition & 0 deletions src/locales/ja/builder.json
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@
"ITEM_MENU_CREATE_SHORTCUT_MENU_ITEM": "ショートカットを作成する",
"ITEM_MENU_FLAG_MENU_ITEM": "フラグ",
"ITEM_MENU_EXPORT_RAW_MENU_ITEM": "ZIPファイルとしてエクスポート",
"ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM": "ZIPをダウンロード",
"ITEM_METADATA_CREATED_AT_TITLE": "創造",
"ITEM_METADATA_CREATOR_TITLE": "クリエイター",
"ITEM_METADATA_LINK_TITLE": "リンク",
Expand Down
55 changes: 55 additions & 0 deletions src/modules/builder/components/common/PublicExportZipButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { type JSX } from 'react';
import { useTranslation } from 'react-i18next';

import { ListItemIcon, MenuItem } from '@mui/material';

import { PackageIcon } from 'lucide-react';

import { NS } from '@/config/constants';
import { buildPublicExportZipButtonId } from '@/config/selectors';
import type { GenericItem } from '@/openapi/client';

type Props = {
itemId: GenericItem['id'];

/**
* ui context the button is located
*/
dataUmamiContext?: string;

/**
* called when the download starts, e.g. to close the parent menu
*/
onClick?: () => void;
};

/**
* Download a public folder as zip, for visitors who are not logged in
* The export is served by the admin app, which also serves this client,
* so the path is absolute without a host
*/
const PublicExportZipButton = ({
itemId,
dataUmamiContext,
onClick,
}: Props): JSX.Element => {
const { t } = useTranslation(NS.Builder);

return (
<MenuItem
id={buildPublicExportZipButtonId(itemId)}
component="a"
href={`/public/folders/${itemId}/export`}
onClick={onClick}
data-umami-event="public-export-zip"
data-umami-event-context={dataUmamiContext}
>
<ListItemIcon>
<PackageIcon />
</ListItemIcon>
{t('ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM')}
</MenuItem>
);
};

export default PublicExportZipButton;
53 changes: 41 additions & 12 deletions src/modules/builder/components/item/header/Actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { PackedItem } from '@/openapi/client';
import { ActionButton } from '@/ui/types';

import ExportRawZipButton from '~builder/components/common/ExportRawZipButton';
import PublicExportZipButton from '~builder/components/common/PublicExportZipButton';
import useModalStatus from '~builder/components/hooks/useModalStatus';
import DownloadButton from '~builder/components/main/DownloadButton';

Expand Down Expand Up @@ -43,7 +44,7 @@ const internalId = 'menu';
const Actions = ({ item }: Props): JSX.Element[] | null => {
const { copyOpen } = useSearch({ from: '/builder/items/$itemId' });
const { t } = useTranslation(NS.Common, { keyPrefix: 'ARIA' });
const { data: member } = hooks.useCurrentMember();
const { data: member, isPending: isMemberPending } = hooks.useCurrentMember();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: MouseEvent<HTMLButtonElement>): void => {
Expand All @@ -68,10 +69,47 @@ const Actions = ({ item }: Props): JSX.Element[] | null => {
const canAdmin =
item.permission && PermissionLevelCompare.gte(item.permission, 'admin');

if (!member?.id) {
const moreButton = (
<IconButton
key="moreVertButton"
aria-label={t('MORE')}
aria-controls={open ? internalId : undefined}
aria-haspopup="true"
aria-expanded={open}
onClick={handleClick}
>
<MoreVerticalIcon />
</IconButton>
);

// wait for the current member to avoid showing the logged out menu
if (isMemberPending) {
return null;
}

if (!member?.id) {
// logged out visitors can only download the zip of a public folder
if (item.type !== 'folder' || !item.public) {
return null;
}
return [
moreButton,
<Menu
key="menu"
id={internalId}
anchorEl={anchorEl}
open={open}
onClose={closeMenu}
>
<PublicExportZipButton
itemId={item.id}
dataUmamiContext="header"
onClick={closeMenu}
/>
</Menu>,
];
}

const downloadButton =
item.type === 'folder' ? (
<ExportRawZipButton
Expand Down Expand Up @@ -99,16 +137,7 @@ const Actions = ({ item }: Props): JSX.Element[] | null => {
onClose={closeCreateShortcutModal}
open={isCreateShortcutOpen}
/>,
<IconButton
key="moreVertButton"
aria-label={t('MORE')}
aria-controls={open ? internalId : undefined}
aria-haspopup="true"
aria-expanded={open}
onClick={handleClick}
>
<MoreVerticalIcon />
</IconButton>,
moreButton,
<Menu
key="menu"
id={internalId}
Expand Down
14 changes: 12 additions & 2 deletions src/modules/builder/components/main/ItemMenuContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import ExportRawZipButton from '../common/ExportRawZipButton';
import FlagButton from '../common/FlagButton';
import HideButton from '../common/HideButton';
import PinButton from '../common/PinButton';
import PublicExportZipButton from '../common/PublicExportZipButton';
import RecycleButton from '../common/RecycleButton';
import useModalStatus from '../hooks/useModalStatus';
import CopyButton from '../item/copy/CopyButton';
Expand Down Expand Up @@ -55,8 +56,10 @@ function GuestAndPublicMenu({
setAnchorEl(null);
};

// logged out user see nothing for public folder
if (!account && item.type === 'folder') {
const isPublicFolder = item.type === 'folder' && Boolean(item.public);

// logged out user see nothing for a folder, except the zip of a public one
if (!account && item.type === 'folder' && !isPublicFolder) {
return null;
}

Expand All @@ -75,6 +78,13 @@ function GuestAndPublicMenu({
{item.type !== 'folder' && (
<DownloadButton item={item} type={ActionButton.MENU_ITEM} />
)}
{!account && isPublicFolder && (
<PublicExportZipButton
itemId={item.id}
dataUmamiContext="card"
onClick={closeMenu}
/>
)}
{account?.id ? <FlagButton key="flag" itemId={item.id} /> : false}
</Menu>
</>
Expand Down
2 changes: 2 additions & 0 deletions src/modules/builder/langs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ export const BUILDER = {
ITEM_MENU_DUPLICATE_MENU_ITEM: 'ITEM_MENU_DUPLICATE_MENU_ITEM',
ITEM_MENU_FLAG_MENU_ITEM: 'ITEM_MENU_FLAG_MENU_ITEM',
ITEM_MENU_EXPORT_RAW_MENU_ITEM: 'ITEM_MENU_EXPORT_RAW_MENU_ITEM',
ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM:
'ITEM_MENU_PUBLIC_EXPORT_ZIP_MENU_ITEM',
ITEM_METADATA_CREATED_AT_TITLE: 'ITEM_METADATA_CREATED_AT_TITLE',
ITEM_METADATA_LINK_TITLE: 'ITEM_METADATA_LINK_TITLE',
ITEM_METADATA_SIZE_TITLE: 'ITEM_METADATA_SIZE_TITLE',
Expand Down
Loading