1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-08-04 21:15:25 +02:00

feat: Move infrequent card actions to more actions menu

This commit is contained in:
Maksim Eltyshev 2025-07-22 21:43:55 +02:00
parent df023439c1
commit 6dfb5ba024
29 changed files with 253 additions and 156 deletions

View file

@ -0,0 +1,153 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Icon, Menu } from 'semantic-ui-react';
import { Popup } from '../../../lib/custom-ui';
import selectors from '../../../selectors';
import entryActions from '../../../entry-actions';
import { useSteps } from '../../../hooks';
import { isListArchiveOrTrash } from '../../../utils/record-helpers';
import { BoardMembershipRoles } from '../../../constants/Enums';
import SelectCardTypeStep from '../SelectCardTypeStep';
import MoveCardStep from '../MoveCardStep';
import styles from './MoreActionsStep.module.scss';
const StepTypes = {
EDIT_TYPE: 'EDIT_TYPE',
MOVE: 'MOVE',
};
const MoreActionsStep = React.memo(({ onClose }) => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const card = useSelector(selectors.selectCurrentCard);
const board = useSelector(selectors.selectCurrentBoard);
const { canEditType, canDuplicate, canMove } = useSelector((state) => {
const list = selectListById(state, card.listId);
if (isListArchiveOrTrash(list)) {
return {
canEditType: false,
canDuplicate: false,
canMove: false,
};
}
const boardMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
const isEditor = !!boardMembership && boardMembership.role === BoardMembershipRoles.EDITOR;
return {
canEditType: isEditor,
canDuplicate: isEditor,
canMove: isEditor,
};
}, shallowEqual);
const dispatch = useDispatch();
const [t] = useTranslation();
const [step, openStep, handleBack] = useSteps();
const handleTypeSelect = useCallback(
(type) => {
dispatch(
entryActions.updateCurrentCard({
type,
}),
);
},
[dispatch],
);
const handleDuplicateClick = useCallback(() => {
dispatch(
entryActions.duplicateCurrentCard({
name: `${card.name} (${t('common.copy', {
context: 'inline',
})})`,
}),
);
onClose();
}, [onClose, card.name, dispatch, t]);
const handleEditTypeClick = useCallback(() => {
openStep(StepTypes.EDIT_TYPE);
}, [openStep]);
const handleMoveClick = useCallback(() => {
openStep(StepTypes.MOVE);
}, [openStep]);
if (step) {
switch (step.type) {
case StepTypes.EDIT_TYPE:
return (
<SelectCardTypeStep
withButton
defaultValue={card.type}
title="common.editType"
buttonContent="action.save"
onSelect={handleTypeSelect}
onBack={handleBack}
onClose={onClose}
/>
);
case StepTypes.MOVE:
return <MoveCardStep id={card.id} onBack={handleBack} onClose={onClose} />;
default:
}
}
return (
<>
<Popup.Header>
{t('common.moreActions', {
context: 'title',
})}
</Popup.Header>
<Popup.Content>
<Menu secondary vertical className={styles.menu}>
{!board.limitCardTypesToDefaultOne && canEditType && (
<Menu.Item className={styles.menuItem} onClick={handleEditTypeClick}>
<Icon name="map outline" className={styles.menuItemIcon} />
{t('action.editType', {
context: 'title',
})}
</Menu.Item>
)}
{canDuplicate && (
<Menu.Item className={styles.menuItem} onClick={handleDuplicateClick}>
<Icon name="copy outline" className={styles.menuItemIcon} />
{t('action.duplicateCard', {
context: 'title',
})}
</Menu.Item>
)}
{canMove && (
<Menu.Item className={styles.menuItem} onClick={handleMoveClick}>
<Icon name="share square outline" className={styles.menuItemIcon} />
{t('action.moveCard', {
context: 'title',
})}
</Menu.Item>
)}
</Menu>
</Popup.Content>
</>
);
});
MoreActionsStep.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default MoreActionsStep;

View file

@ -0,0 +1,21 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
:global(#app) {
.menu {
margin: -7px -12px -5px;
width: calc(100% + 24px);
}
.menuItem {
margin: 0;
padding-left: 14px;
}
.menuItemIcon {
float: left;
margin: 0 0.5em 0 0;
}
}

View file

@ -4,7 +4,6 @@
*/
import React, { useCallback, useContext, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
@ -24,12 +23,11 @@ import TaskLists from './TaskLists';
import CustomFieldGroups from './CustomFieldGroups';
import Communication from './Communication';
import CreationDetailsStep from './CreationDetailsStep';
import MoreActionsStep from './MoreActionsStep';
import DueDateChip from '../DueDateChip';
import StopwatchChip from '../StopwatchChip';
import SelectCardTypeStep from '../SelectCardTypeStep';
import EditDueDateStep from '../EditDueDateStep';
import EditStopwatchStep from '../EditStopwatchStep';
import MoveCardStep from '../MoveCardStep';
import ExpandableMarkdown from '../../common/ExpandableMarkdown';
import EditMarkdown from '../../common/EditMarkdown';
import ConfirmationStep from '../../common/ConfirmationStep';
@ -45,7 +43,7 @@ import AddCustomFieldGroupStep from '../../custom-field-groups/AddCustomFieldGro
import styles from './ProjectContent.module.scss';
const ProjectContent = React.memo(({ onClose }) => {
const ProjectContent = React.memo(() => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const selectPrevListById = useMemo(() => selectors.makeSelectListById(), []);
@ -155,17 +153,6 @@ const ProjectContent = React.memo(({ onClose }) => {
[dispatch],
);
const handleTypeSelect = useCallback(
(type) => {
dispatch(
entryActions.updateCurrentCard({
type,
}),
);
},
[dispatch],
);
const handleNameUpdate = useCallback(
(name) => {
dispatch(
@ -198,18 +185,6 @@ const ProjectContent = React.memo(({ onClose }) => {
);
}, [card.stopwatch, dispatch]);
const handleDuplicateClick = useCallback(() => {
dispatch(
entryActions.duplicateCurrentCard({
name: `${card.name} (${t('common.copy', {
context: 'inline',
})})`,
}),
);
onClose();
}, [onClose, card.name, dispatch, t]);
const handleRestoreClick = useCallback(() => {
dispatch(entryActions.moveCurrentCard(card.prevListId, undefined, true));
}, [card.prevListId, dispatch]);
@ -304,13 +279,12 @@ const ProjectContent = React.memo(({ onClose }) => {
const BoardMembershipsPopup = usePopupInClosableContext(BoardMembershipsStep);
const LabelsPopup = usePopupInClosableContext(LabelsStep);
const ListsPopup = usePopupInClosableContext(ListsStep);
const SelectCardTypePopup = usePopupInClosableContext(SelectCardTypeStep);
const EditDueDatePopup = usePopupInClosableContext(EditDueDateStep);
const EditStopwatchPopup = usePopupInClosableContext(EditStopwatchStep);
const AddTaskListPopup = usePopupInClosableContext(AddTaskListStep);
const AddAttachmentPopup = usePopupInClosableContext(AddAttachmentStep);
const AddCustomFieldGroupPopup = usePopupInClosableContext(AddCustomFieldGroupStep);
const MoveCardPopup = usePopupInClosableContext(MoveCardStep);
const MoreActionsPopup = usePopupInClosableContext(MoreActionsStep);
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
return (
@ -706,40 +680,6 @@ const ProjectContent = React.memo(({ onClose }) => {
)}
</Button>
)}
{!board.limitCardTypesToDefaultOne && canEditType && (
<SelectCardTypePopup
withButton
defaultValue={card.type}
title="common.editType"
buttonContent="action.save"
onSelect={handleTypeSelect}
>
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
<Icon name="map outline" className={styles.actionIcon} />
{t('action.editType', {
context: 'title',
})}
</Button>
</SelectCardTypePopup>
)}
{canDuplicate && (
<Button
fluid
className={classNames(styles.actionButton, styles.hidable)}
onClick={handleDuplicateClick}
>
<Icon name="copy outline" className={styles.actionIcon} />
{t('action.duplicate')}
</Button>
)}
{canMove && (
<MoveCardPopup id={card.id}>
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
<Icon name="share square outline" className={styles.actionIcon} />
{t('action.move')}
</Button>
</MoveCardPopup>
)}
{canRestore && (isInArchiveList || isInTrashList) && (
<Button
fluid
@ -789,6 +729,16 @@ const ProjectContent = React.memo(({ onClose }) => {
</Button>
</ConfirmationPopup>
)}
{((!board.limitCardTypesToDefaultOne && canEditType) ||
canDuplicate ||
canMove) && (
<MoreActionsPopup>
<Button fluid className={classNames(styles.moreActionsButton, styles.hidable)}>
<Icon name="ellipsis horizontal" className={styles.moreActionsButtonIcon} />
{t('common.moreActions')}
</Button>
</MoreActionsPopup>
)}
</div>
)}
</div>
@ -798,8 +748,4 @@ const ProjectContent = React.memo(({ onClose }) => {
);
});
ProjectContent.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default ProjectContent;

View file

@ -260,6 +260,28 @@
position: relative;
}
.moreActionsButton {
background: transparent;
box-shadow: none;
color: #6b808c;
font-weight: normal;
margin-top: 8px;
padding: 6px 8px 6px 18px;
text-align: left;
text-decoration: underline;
transition: none;
&:hover {
background: rgba(9, 30, 66, 0.08);
color: #092d42;
}
}
.moreActionsButtonIcon {
margin-right: 8px;
text-decoration: none;
}
.sidebarPadding {
padding: 8px 16px 8px 8px;

View file

@ -4,7 +4,6 @@
*/
import React, { useCallback, useContext, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
@ -24,8 +23,7 @@ import NameField from '../NameField';
import CustomFieldGroups from '../CustomFieldGroups';
import Communication from '../Communication';
import CreationDetailsStep from '../CreationDetailsStep';
import SelectCardTypeStep from '../../SelectCardTypeStep';
import MoveCardStep from '../../MoveCardStep';
import MoreActionsStep from '../MoreActionsStep';
import Markdown from '../../../common/Markdown';
import EditMarkdown from '../../../common/EditMarkdown';
import ConfirmationStep from '../../../common/ConfirmationStep';
@ -40,7 +38,7 @@ import AddCustomFieldGroupStep from '../../../custom-field-groups/AddCustomField
import styles from './StoryContent.module.scss';
const StoryContent = React.memo(({ onClose }) => {
const StoryContent = React.memo(() => {
const selectListById = useMemo(() => selectors.makeSelectListById(), []);
const selectPrevListById = useMemo(() => selectors.makeSelectListById(), []);
const selectAttachmentById = useMemo(() => selectors.makeSelectAttachmentById(), []);
@ -150,17 +148,6 @@ const StoryContent = React.memo(({ onClose }) => {
[dispatch],
);
const handleTypeSelect = useCallback(
(type) => {
dispatch(
entryActions.updateCurrentCard({
type,
}),
);
},
[dispatch],
);
const handleNameUpdate = useCallback(
(name) => {
dispatch(
@ -183,18 +170,6 @@ const StoryContent = React.memo(({ onClose }) => {
[dispatch],
);
const handleDuplicateClick = useCallback(() => {
dispatch(
entryActions.duplicateCurrentCard({
name: `${card.name} (${t('common.copy', {
context: 'inline',
})})`,
}),
);
onClose();
}, [onClose, card.name, dispatch, t]);
const handleRestoreClick = useCallback(() => {
dispatch(entryActions.moveCurrentCard(card.prevListId, undefined, true));
}, [card.prevListId, dispatch]);
@ -300,10 +275,9 @@ const StoryContent = React.memo(({ onClose }) => {
const BoardMembershipsPopup = usePopupInClosableContext(BoardMembershipsStep);
const LabelsPopup = usePopupInClosableContext(LabelsStep);
const ListsPopup = usePopupInClosableContext(ListsStep);
const SelectCardTypePopup = usePopupInClosableContext(SelectCardTypeStep);
const AddAttachmentPopup = usePopupInClosableContext(AddAttachmentStep);
const AddCustomFieldGroupPopup = usePopupInClosableContext(AddCustomFieldGroupStep);
const MoveCardPopup = usePopupInClosableContext(MoveCardStep);
const MoreActionsPopup = usePopupInClosableContext(MoreActionsStep);
const ConfirmationPopup = usePopupInClosableContext(ConfirmationStep);
return (
@ -614,40 +588,6 @@ const StoryContent = React.memo(({ onClose }) => {
)}
</Button>
)}
{!board.limitCardTypesToDefaultOne && canEditType && (
<SelectCardTypePopup
withButton
defaultValue={card.type}
title="common.editType"
buttonContent="action.save"
onSelect={handleTypeSelect}
>
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
<Icon name="map outline" className={styles.actionIcon} />
{t('action.editType', {
context: 'title',
})}
</Button>
</SelectCardTypePopup>
)}
{canDuplicate && (
<Button
fluid
className={classNames(styles.actionButton, styles.hidable)}
onClick={handleDuplicateClick}
>
<Icon name="copy outline" className={styles.actionIcon} />
{t('action.duplicate')}
</Button>
)}
{canMove && (
<MoveCardPopup id={card.id}>
<Button fluid className={classNames(styles.actionButton, styles.hidable)}>
<Icon name="share square outline" className={styles.actionIcon} />
{t('action.move')}
</Button>
</MoveCardPopup>
)}
{canRestore && (isInArchiveList || isInTrashList) && (
<Button
fluid
@ -697,6 +637,16 @@ const StoryContent = React.memo(({ onClose }) => {
</Button>
</ConfirmationPopup>
)}
{((!board.limitCardTypesToDefaultOne && canEditType) ||
canDuplicate ||
canMove) && (
<MoreActionsPopup>
<Button fluid className={classNames(styles.moreActionsButton, styles.hidable)}>
<Icon name="ellipsis horizontal" className={styles.moreActionsButtonIcon} />
{t('common.moreActions')}
</Button>
</MoreActionsPopup>
)}
</div>
)}
</div>
@ -706,8 +656,4 @@ const StoryContent = React.memo(({ onClose }) => {
);
});
StoryContent.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default StoryContent;

View file

@ -286,6 +286,28 @@
margin-bottom: 20px;
}
.moreActionsButton {
background: transparent;
box-shadow: none;
color: #6b808c;
font-weight: normal;
margin-top: 8px;
padding: 6px 8px 6px 18px;
text-align: left;
text-decoration: underline;
transition: none;
&:hover {
background: rgba(9, 30, 66, 0.08);
color: #092d42;
}
}
.moreActionsButtonIcon {
margin-right: 8px;
text-decoration: none;
}
.sidebarPadding {
padding: 8px 16px 8px 8px;

View file

@ -197,7 +197,6 @@ export default {
deleteTask: 'حذف المهمة',
deleteTask_title: 'حذف المهمة',
deleteUser: 'حذف المستخدم',
duplicate: 'تكرار',
duplicateCard_title: 'تكرار البطاقة',
edit: 'تعديل',
editDescription_title: 'تعديل الوصف',

View file

@ -202,7 +202,6 @@ export default {
deleteTask: 'Изтриване на задача',
deleteTask_title: 'Изтриване на задача',
deleteUser: 'Изтриване на потребител',
duplicate: 'Направи дубликат',
duplicateCard_title: 'Дублирана карта',
edit: 'Редактиране',
editDescription_title: 'Редактиране на описание',

View file

@ -354,7 +354,6 @@ export default {
deleteUser: 'Smazat uživatele',
deleteUser_title: 'Smazat uživatele',
dismissAll: 'Vše přečteno',
duplicate: 'Duplikovat',
duplicateCard_title: 'Duplikovat kartu',
edit: 'Upravit',
editColor_title: 'Upravit barvu',

View file

@ -382,7 +382,6 @@ export default {
deleteUser: 'Slet bruger',
deleteUser_title: 'Slet bruger',
dismissAll: 'Afvis alle',
duplicate: 'Duplikér',
duplicateCard_title: 'Duplikér kort',
edit: 'Rediger',
editColor_title: 'Rediger farve',

View file

@ -371,7 +371,6 @@ export default {
deleteUser: 'Benutzer löschen',
deleteUser_title: 'Benutzer löschen',
dismissAll: 'Alle verwerfen',
duplicate: 'Duplizieren',
duplicateCard_title: 'Karte duplizieren',
edit: 'Bearbeiten',
editColor_title: 'Farbe bearbeiten',

View file

@ -398,7 +398,6 @@ export default {
deleteUser: 'Διαγραφή χρήστη',
deleteUser_title: 'Διαγραφή χρήστη',
dismissAll: 'Απόρριψη όλων',
duplicate: 'Διπλασιασμός',
duplicateCard_title: 'Διπλασιασμός κάρτας',
edit: 'Επεξεργασία',
editColor_title: 'Επεξεργασία χρώματος',

View file

@ -216,6 +216,8 @@ export default {
memberActions_title: 'Member Actions',
members: 'Members',
minutes: 'Minutes',
moreActions: 'More actions',
moreActions_title: 'More Actions',
moveCard_title: 'Move Card',
myOwn_title: 'My Own',
name: 'Name',
@ -391,7 +393,6 @@ export default {
deleteUser_title: 'Delete User',
deleteWebhook: 'Delete webhook',
dismissAll: 'Dismiss all',
duplicate: 'Duplicate',
duplicateCard_title: 'Duplicate Card',
edit: 'Edit',
editColor_title: 'Edit Color',

View file

@ -211,6 +211,8 @@ export default {
memberActions_title: 'Member Actions',
members: 'Members',
minutes: 'Minutes',
moreActions: 'More actions',
moreActions_title: 'More Actions',
moveCard_title: 'Move Card',
myOwn_title: 'My Own',
name: 'Name',
@ -386,7 +388,6 @@ export default {
deleteUser_title: 'Delete User',
deleteWebhook: 'Delete webhook',
dismissAll: 'Dismiss all',
duplicate: 'Duplicate',
duplicateCard_title: 'Duplicate Card',
edit: 'Edit',
editColor_title: 'Edit Color',

View file

@ -374,7 +374,6 @@ export default {
deleteUser: 'Borrar usuario',
deleteUser_title: 'Borrar Usuario',
dismissAll: 'Descartar todo',
duplicate: 'Duplicar',
duplicateCard_title: 'Duplicar tarjeta',
edit: 'Editar',
editColor_title: 'Editar color',

View file

@ -380,7 +380,6 @@ export default {
deleteUser: 'Kustuta kasutaja',
deleteUser_title: 'Kustuta kasutaja',
dismissAll: 'Eemalda kõik',
duplicate: 'Dubleeri',
duplicateCard_title: 'Duplikaardi loomine',
edit: 'Muuda',
editColor_title: 'Muuda värvi',

View file

@ -199,7 +199,6 @@ export default {
deleteTask: 'حذف وظیفه',
deleteTask_title: 'حذف وظیفه',
deleteUser: 'حذف کاربر',
duplicate: 'تکرار',
duplicateCard_title: 'تکرار کارت',
edit: 'ویرایش',
editDescription_title: 'ویرایش توضیحات',

View file

@ -380,7 +380,6 @@ export default {
deleteUser: 'Poista käyttäjä',
deleteUser_title: 'Poista käyttäjä',
dismissAll: 'Sulje kaikki',
duplicate: 'Monista',
duplicateCard_title: 'Monista kortti',
edit: 'Muokkaa',
editColor_title: 'Muokkaa väriä',

View file

@ -393,7 +393,6 @@ export default {
deleteUser_title: 'Supprimer lutilisateur',
deleteWebhook: 'Supprimer le webhook',
dismissAll: 'Tout rejeter',
duplicate: 'Dupliquer',
duplicateCard_title: 'Dupliquer la carte',
edit: 'Modifier',
editColor_title: 'Modifier la couleur',

View file

@ -200,7 +200,6 @@ export default {
deleteTask: 'Feladat törlése',
deleteTask_title: 'Feladat törlése',
deleteUser: 'Felhasználó törlése',
duplicate: 'Másolás',
duplicateCard_title: 'Kártya másolása',
edit: 'Szerkesztés',
editDescription_title: 'Leírás szerkesztése',

View file

@ -377,7 +377,6 @@ export default {
deleteUser: 'Elimina utente',
deleteUser_title: 'Elimina utente',
dismissAll: 'Ignora tutto',
duplicate: 'Duplica',
duplicateCard_title: 'Duplica scheda',
edit: 'Modifica',
editColor_title: 'Modifica colore',

View file

@ -201,7 +201,6 @@ export default {
deleteTask_title: '작업 삭제',
deleteUser: '사용자 삭제',
dismissAll: '모두 해제',
duplicate: '복제',
duplicateCard_title: '카드 복제',
edit: '편집',
editColor_title: '색상 편집',

View file

@ -354,7 +354,6 @@ export default {
deleteUser: 'Usuń użytkownika',
deleteUser_title: 'Usuń Użytkownika',
dismissAll: 'Odrzuć wszystkie',
duplicate: 'Duplikuj',
duplicateCard_title: 'Duplikuj Kartę',
edit: 'Edytuj',
editColor_title: 'Edytuj Kolor',

View file

@ -360,7 +360,6 @@ export default {
deleteUser: 'Удалить пользователя',
deleteUser_title: 'Удалить пользователя',
dismissAll: 'Отклонить все',
duplicate: 'Дублировать',
duplicateCard_title: 'Дублировать карточку',
edit: 'Изменить',
editColor_title: 'Изменить цвет',

View file

@ -201,7 +201,6 @@ export default {
deleteTask: 'Обриши задатак',
deleteTask_title: 'Обриши задатак',
deleteUser: 'Обриши корисника',
duplicate: 'Клонирај',
duplicateCard_title: 'Клонирај картицу',
edit: 'Измени',
editDescription_title: 'Измени опис',

View file

@ -198,7 +198,6 @@ export default {
deleteTask: 'Obriši zadatak',
deleteTask_title: 'Obriši zadatak',
deleteUser: 'Obriši korisnika',
duplicate: 'Kloniraj',
duplicateCard_title: 'Kloniraj karticu',
edit: 'Izmeni',
editDescription_title: 'Izmeni opis',

View file

@ -356,7 +356,6 @@ export default {
deleteUser: 'Видалити користувача',
deleteUser_title: 'Видалити Користувача',
dismissAll: 'Скасувати всі',
duplicate: 'Дублювати',
duplicateCard_title: 'Дублювати Картку',
edit: 'Редагувати',
editColor_title: 'Редагувати колір',

View file

@ -368,7 +368,6 @@ export default {
deleteUser_title: '删除用户',
deleteWebhook: '删除Webhook',
dismissAll: '全部忽略',
duplicate: '复制卡片',
duplicateCard_title: '复制卡片',
edit: '编辑',
editColor_title: '编辑颜色',

View file

@ -430,6 +430,7 @@ export function* transferCurrentCard(boardId, listId, index) {
export function* duplicateCard(id, data) {
const localId = yield call(createLocalId);
const { cardId: currentCardId } = yield select(selectors.selectPath);
const { boardId, listId } = yield select(selectors.selectCardById, id);
const index = yield select(selectors.selectCardIndexById, id);
@ -450,6 +451,10 @@ export function* duplicateCard(id, data) {
}),
);
if (id === currentCardId) {
yield call(goToBoard, boardId);
}
let card;
let cardMemberships;
let cardLabels;