diff --git a/client/src/components/boards/BoardActions/Filters.jsx b/client/src/components/boards/BoardActions/Filters.jsx
index e69de29b..528150ad 100644
--- a/client/src/components/boards/BoardActions/Filters.jsx
+++ b/client/src/components/boards/BoardActions/Filters.jsx
@@ -0,0 +1,323 @@
+/*!
+ * Copyright (c) 2024 PLANKA Software GmbH
+ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
+ */
+
+import debounce from 'lodash/debounce';
+import React, { useCallback, useMemo, useState } from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { useDispatch, useSelector } from 'react-redux';
+import { useTranslation } from 'react-i18next';
+import { Icon } from 'semantic-ui-react';
+import { useDidUpdate } from '../../../lib/hooks';
+import { usePopup } from '../../../lib/popup';
+import { Input, Tooltip } from '../../../lib/custom-ui';
+
+import selectors from '../../../selectors';
+import entryActions from '../../../entry-actions';
+import { useNestedRef } from '../../../hooks';
+import { BoardViews, LabelFilterModes } from '../../../constants/Enums';
+import UserAvatar from '../../users/UserAvatar';
+import BoardMembershipsStep from '../../board-memberships/BoardMembershipsStep';
+import LabelChip from '../../labels/LabelChip';
+import LabelsStep from '../../labels/LabelsStep';
+import ListsFilterStep from '../../lists/ListsFilterStep';
+
+import styles from './Filters.module.scss';
+
+const FilterListChip = React.memo(({ id, onClick }) => {
+ const selectListById = useMemo(() => selectors.makeSelectListById(), []);
+ const list = useSelector((state) => selectListById(state, id));
+ const [t] = useTranslation();
+
+ const handleClick = useCallback(() => {
+ onClick(id);
+ }, [id, onClick]);
+
+ if (!list) {
+ return null;
+ }
+
+ return (
+
+ );
+});
+
+FilterListChip.propTypes = {
+ id: PropTypes.string.isRequired,
+ onClick: PropTypes.func.isRequired,
+};
+
+const Filters = React.memo(() => {
+ const board = useSelector(selectors.selectCurrentBoard);
+ const userIds = useSelector(selectors.selectFilterUserIdsForCurrentBoard);
+ const labelIds = useSelector(selectors.selectFilterLabelIdsForCurrentBoard);
+ const excludedLabelIds = useSelector(selectors.selectFilterExcludedLabelIdsForCurrentBoard);
+ const listIds = useSelector(selectors.selectFilterListIdsForCurrentBoard);
+ const currentUserId = useSelector(selectors.selectCurrentUserId);
+
+ const withCurrentUserSelector = useSelector(
+ (state) => !!selectors.selectCurrentUserMembershipForCurrentBoard(state),
+ );
+
+ const dispatch = useDispatch();
+ const [t] = useTranslation();
+ const [search, setSearch] = useState(board.search);
+ const [isSearchFocused, setIsSearchFocused] = useState(false);
+
+ const debouncedSearch = useMemo(
+ () =>
+ debounce((nextSearch) => {
+ dispatch(entryActions.searchInCurrentBoard(nextSearch));
+ }, 400),
+ [dispatch],
+ );
+
+ const [searchFieldRef, handleSearchFieldRef] = useNestedRef('inputRef');
+
+ const labelModes = useMemo(
+ () => ({
+ ...labelIds.reduce(
+ (result, labelId) => ({
+ ...result,
+ [labelId]: LabelFilterModes.INCLUDE,
+ }),
+ {},
+ ),
+ ...excludedLabelIds.reduce(
+ (result, labelId) => ({
+ ...result,
+ [labelId]: LabelFilterModes.EXCLUDE,
+ }),
+ {},
+ ),
+ }),
+ [labelIds, excludedLabelIds],
+ );
+
+ const cancelSearch = useCallback(() => {
+ debouncedSearch.cancel();
+ setSearch('');
+ dispatch(entryActions.searchInCurrentBoard(''));
+ searchFieldRef.current.blur();
+ }, [dispatch, debouncedSearch, searchFieldRef]);
+
+ const handleUserSelect = useCallback(
+ (userId) => {
+ dispatch(entryActions.addUserToFilterInCurrentBoard(userId));
+ },
+ [dispatch],
+ );
+
+ const handleCurrentUserSelect = useCallback(() => {
+ dispatch(entryActions.addUserToFilterInCurrentBoard(currentUserId));
+ }, [currentUserId, dispatch]);
+
+ const handleUserDeselect = useCallback(
+ (userId) => {
+ dispatch(entryActions.removeUserFromFilterInCurrentBoard(userId));
+ },
+ [dispatch],
+ );
+
+ const handleUserClick = useCallback(
+ ({
+ currentTarget: {
+ dataset: { id: userId },
+ },
+ }) => {
+ dispatch(entryActions.removeUserFromFilterInCurrentBoard(userId));
+ },
+ [dispatch],
+ );
+
+ const handleListSelect = useCallback(
+ (listId) => {
+ dispatch(entryActions.addListToFilterInCurrentBoard(listId));
+ },
+ [dispatch],
+ );
+
+ const handleListDeselect = useCallback(
+ (listId) => {
+ dispatch(entryActions.removeListFromFilterInCurrentBoard(listId));
+ },
+ [dispatch],
+ );
+
+ const handleLabelClick = useCallback(
+ ({
+ currentTarget: {
+ dataset: { id: labelId },
+ },
+ }) => {
+ dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, LabelFilterModes.NONE));
+ },
+ [dispatch],
+ );
+
+ const handleLabelModeChange = useCallback(
+ (labelId, mode) => {
+ dispatch(entryActions.updateLabelFilterInCurrentBoard(labelId, mode));
+ },
+ [dispatch],
+ );
+
+ const handleSearchChange = useCallback(
+ (_, { value }) => {
+ setSearch(value);
+ debouncedSearch(value);
+ },
+ [debouncedSearch],
+ );
+
+ const handleSearchFocus = useCallback(() => {
+ setIsSearchFocused(true);
+ }, []);
+
+ const handleSearchKeyDown = useCallback(
+ (event) => {
+ if (event.key === 'Escape') {
+ cancelSearch();
+ }
+ },
+ [cancelSearch],
+ );
+
+ const handleSearchBlur = useCallback(() => {
+ setIsSearchFocused(false);
+ }, []);
+
+ const handleCancelSearchClick = useCallback(() => {
+ cancelSearch();
+ }, [cancelSearch]);
+
+ useDidUpdate(() => {
+ setSearch(board.search);
+ }, [board.search]);
+
+ const BoardMembershipsPopup = usePopup(BoardMembershipsStep);
+ const LabelsPopup = usePopup(LabelsStep);
+ const ListsFilterPopup = usePopup(ListsFilterStep);
+
+ const isSearchActive = search || isSearchFocused;
+ const isListView = board.view === BoardViews.LIST;
+
+ const handleNoMemberClick = useCallback(() => {
+ if (board.filterNoMember) {
+ dispatch(entryActions.removeNoMemberFromFilterInCurrentBoard());
+ } else {
+ dispatch(entryActions.setNoMemberToFilterInCurrentBoard());
+ }
+ }, [dispatch, board.filterNoMember]);
+
+ return (
+ <>
+
+
+
+
+
+ {userIds.length === 0 && withCurrentUserSelector && (
+
+
+
+ )}
+ {userIds.map((userId) => (
+
+
+
+ ))}
+
+
+
+
+
+ {labelIds.map((labelId) => (
+
+
+
+ ))}
+ {excludedLabelIds.map((labelId) => (
+
+
+
+ ))}
+
+ {isListView && (
+
+
+
+
+ {listIds.map((listId) => (
+
+ ))}
+
+ )}
+
+
+ ) : (
+ 'search'
+ )
+ }
+ className={classNames(styles.search, !isSearchActive && styles.searchInactive)}
+ onFocus={handleSearchFocus}
+ onKeyDown={handleSearchKeyDown}
+ onChange={handleSearchChange}
+ onBlur={handleSearchBlur}
+ />
+
+ >
+ );
+});
+
+export default Filters;
diff --git a/client/src/models/Board.js b/client/src/models/Board.js
index e69de29b..0acea83b 100755
--- a/client/src/models/Board.js
+++ b/client/src/models/Board.js
@@ -0,0 +1,540 @@
+/*!
+ * Copyright (c) 2024 PLANKA Software GmbH
+ * Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
+ */
+
+import { attr, fk, many } from 'redux-orm';
+
+import BaseModel from './BaseModel';
+import buildSearchParts from '../utils/build-search-parts';
+import filterCardLabels from '../utils/filter-card-labels';
+import { isListKanban } from '../utils/record-helpers';
+import { recallBoardView } from '../utils/board-view-memory';
+import ActionTypes from '../constants/ActionTypes';
+import Config from '../constants/Config';
+import { BoardContexts, BoardViews, LabelFilterModes } from '../constants/Enums';
+
+const prepareFetchedBoard = (board) => ({
+ ...board,
+ isFetching: false,
+ context: BoardContexts.BOARD,
+ // Whatever this window was last looking at, where that choice still applies —
+ // see `utils/board-view-memory`. A board's configured default is where it
+ // starts, not where it has to stay.
+ view: recallBoardView(board.id, BoardContexts.BOARD) || board.defaultView,
+ search: '',
+});
+
+export default class extends BaseModel {
+ static modelName = 'Board';
+
+ static fields = {
+ id: attr(),
+ position: attr(),
+ name: attr(),
+ defaultView: attr(),
+ defaultCardType: attr(),
+ limitCardTypesToDefaultOne: attr(),
+ alwaysDisplayCardCreator: attr(),
+ displayCardAges: attr(),
+ expandTaskListsByDefault: attr(),
+ context: attr(),
+ view: attr(),
+ search: attr(),
+ isSubscribed: attr({
+ getDefault: () => false,
+ }),
+ isFetching: attr({
+ getDefault: () => null,
+ }),
+ lastActivityId: attr({
+ getDefault: () => null,
+ }),
+ isActivitiesFetching: attr({
+ getDefault: () => false,
+ }),
+ isAllActivitiesFetched: attr({
+ getDefault: () => null,
+ }),
+ projectId: fk({
+ to: 'Project',
+ as: 'project',
+ relatedName: 'boards',
+ }),
+ memberUsers: many({
+ to: 'User',
+ through: 'BoardMembership',
+ relatedName: 'boards',
+ }),
+ filterUsers: many('User', 'filterBoards'),
+ filterLabels: many('Label', 'filterBoards'),
+ filterExcludedLabels: many('Label', 'filterExcludedBoards'),
+ filterNoMember: attr({
+ getDefault: () => false,
+ }),
+ filterLists: many('List', 'filterBoards'),
+ };
+
+ static reducer({ type, payload }, Board) {
+ switch (type) {
+ case ActionTypes.LOCATION_CHANGE_HANDLE:
+ if (payload.board) {
+ Board.upsert(prepareFetchedBoard(payload.board));
+ }
+
+ break;
+ case ActionTypes.LOCATION_CHANGE_HANDLE__BOARD_FETCH:
+ case ActionTypes.BOARD_FETCH:
+ Board.withId(payload.id).update({
+ isFetching: true,
+ });
+
+ break;
+ case ActionTypes.SOCKET_RECONNECT_HANDLE: {
+ const boardIds = payload.boards.map(({ id }) => id);
+
+ Board.all()
+ .toModelArray()
+ .forEach((boardModel) => {
+ if (boardModel.isFetching === null || !boardIds.includes(boardModel.id)) {
+ boardModel.deleteWithClearable();
+ }
+ });
+
+ if (payload.board) {
+ const boardModel = Board.withId(payload.board.id);
+
+ if (boardModel) {
+ boardModel.update(payload.board);
+ } else {
+ Board.upsert(prepareFetchedBoard(payload.board));
+ }
+ }
+
+ payload.boards.forEach((board) => {
+ Board.upsert(board);
+ });
+
+ break;
+ }
+ case ActionTypes.SOCKET_RECONNECT_HANDLE__CORE_FETCH:
+ Board.all()
+ .toModelArray()
+ .forEach((boardModel) => {
+ if (boardModel.id !== payload.currentBoardId) {
+ boardModel.update({
+ isFetching: null,
+ });
+
+ boardModel.deleteRelated(payload.currentUserId, true);
+ }
+ });
+
+ break;
+ case ActionTypes.CORE_INITIALIZE:
+ if (payload.board) {
+ Board.upsert(prepareFetchedBoard(payload.board));
+ }
+
+ payload.boards.forEach((board) => {
+ Board.upsert(board);
+ });
+
+ break;
+ case ActionTypes.USER_UPDATE_HANDLE:
+ Board.all()
+ .toModelArray()
+ .forEach((boardModel) => {
+ if (!payload.boardIds.includes(boardModel.id)) {
+ boardModel.deleteWithRelated(true);
+ }
+ });
+
+ if (payload.board) {
+ Board.upsert(prepareFetchedBoard(payload.board));
+ }
+
+ if (payload.boards) {
+ payload.boards.forEach((board) => {
+ Board.upsert(board);
+ });
+ }
+
+ break;
+ case ActionTypes.USER_TO_BOARD_FILTER_ADD: {
+ const boardModel = Board.withId(payload.boardId);
+
+ if (payload.replace) {
+ boardModel.filterUsers.clear();
+ }
+
+ boardModel.filterUsers.add(payload.id);
+
+ break;
+ }
+ case ActionTypes.USER_FROM_BOARD_FILTER_REMOVE:
+ Board.withId(payload.boardId).filterUsers.remove(payload.id);
+
+ break;
+ case ActionTypes.PROJECT_CREATE_HANDLE:
+ payload.boards.forEach((board) => {
+ Board.upsert(board);
+ });
+
+ break;
+ case ActionTypes.PROJECT_UPDATE_HANDLE:
+ case ActionTypes.PROJECT_MANAGER_CREATE_HANDLE:
+ case ActionTypes.BOARD_MEMBERSHIP_CREATE_HANDLE:
+ if (payload.board) {
+ Board.upsert(prepareFetchedBoard(payload.board));
+ }
+
+ if (payload.boards) {
+ payload.boards.forEach((board) => {
+ Board.upsert(board);
+ });
+ }
+
+ break;
+ case ActionTypes.BOARD_CREATE:
+ case ActionTypes.BOARD_CREATE_HANDLE:
+ case ActionTypes.BOARD_UPDATE__SUCCESS:
+ case ActionTypes.BOARD_UPDATE_HANDLE:
+ Board.upsert(payload.board);
+
+ break;
+ case ActionTypes.BOARD_CREATE__SUCCESS:
+ Board.withId(payload.localId).delete();
+ Board.upsert(payload.board);
+
+ break;
+ case ActionTypes.BOARD_CREATE__FAILURE:
+ Board.withId(payload.localId).delete();
+
+ break;
+ case ActionTypes.BOARD_FETCH__SUCCESS:
+ Board.upsert(prepareFetchedBoard(payload.board));
+
+ break;
+ case ActionTypes.BOARD_FETCH__FAILURE:
+ Board.withId(payload.id).update({
+ isFetching: null,
+ });
+
+ break;
+ case ActionTypes.BOARD_UPDATE:
+ Board.withId(payload.id).update(payload.data);
+
+ break;
+ case ActionTypes.BOARD_CONTEXT_UPDATE: {
+ const boardModel = Board.withId(payload.id);
+
+ boardModel.update({
+ context: payload.value,
+ view: payload.value === BoardContexts.BOARD ? boardModel.defaultView : BoardViews.LIST,
+ });
+
+ break;
+ }
+ case ActionTypes.IN_BOARD_SEARCH:
+ Board.withId(payload.id).update({
+ search: payload.value,
+ });
+
+ break;
+ case ActionTypes.BOARD_DELETE:
+ Board.withId(payload.id).deleteWithRelated();
+
+ break;
+ case ActionTypes.BOARD_DELETE__SUCCESS:
+ case ActionTypes.BOARD_DELETE_HANDLE: {
+ const boardModel = Board.withId(payload.board.id);
+
+ if (boardModel) {
+ boardModel.deleteWithRelated();
+ }
+
+ break;
+ }
+ case ActionTypes.LABEL_TO_BOARD_FILTER_ADD:
+ Board.withId(payload.boardId).filterLabels.add(payload.id);
+
+ break;
+ case ActionTypes.LABEL_FROM_BOARD_FILTER_REMOVE:
+ Board.withId(payload.boardId).filterLabels.remove(payload.id);
+
+ break;
+ case ActionTypes.LABEL_FILTER_IN_BOARD_UPDATE: {
+ const boardModel = Board.withId(payload.boardId);
+
+ try {
+ boardModel.filterLabels.remove(payload.id);
+ } catch {
+ /* empty */
+ }
+
+ try {
+ boardModel.filterExcludedLabels.remove(payload.id);
+ } catch {
+ /* empty */
+ }
+
+ if (payload.mode === LabelFilterModes.INCLUDE) {
+ boardModel.filterLabels.add(payload.id);
+ } else if (payload.mode === LabelFilterModes.EXCLUDE) {
+ boardModel.filterExcludedLabels.add(payload.id);
+ }
+
+ break;
+ }
+ case ActionTypes.LIST_TO_BOARD_FILTER_ADD:
+ Board.withId(payload.boardId).filterLists.add(payload.id);
+
+ break;
+ case ActionTypes.LIST_FROM_BOARD_FILTER_REMOVE:
+ Board.withId(payload.boardId).filterLists.remove(payload.id);
+
+ break;
+ case ActionTypes.ACTIVITIES_IN_BOARD_FETCH:
+ Board.withId(payload.boardId).update({
+ isActivitiesFetching: true,
+ });
+
+ break;
+ case ActionTypes.ACTIVITIES_IN_BOARD_FETCH__SUCCESS:
+ Board.withId(payload.boardId).update({
+ isActivitiesFetching: false,
+ isAllActivitiesFetched: payload.activities.length < Config.ACTIVITIES_LIMIT,
+ ...(payload.activities.length > 0 && {
+ lastActivityId: payload.activities[payload.activities.length - 1].id,
+ }),
+ });
+
+ break;
+ case ActionTypes.NO_MEMBER_TO_BOARD_FILTER_SET: {
+ const boardModel = Board.withId(payload.boardId);
+ boardModel.filterUsers.clear();
+ boardModel.update({ filterNoMember: true });
+ break;
+ }
+ case ActionTypes.NO_MEMBER_FROM_BOARD_FILTER_REMOVE: {
+ const boardModel = Board.withId(payload.boardId);
+ boardModel.update({ filterNoMember: false });
+ break;
+ }
+ default:
+ }
+ }
+
+ getMembershipsQuerySet() {
+ return this.memberships.orderBy(['id.length', 'id']);
+ }
+
+ getLabelsQuerySet() {
+ return this.labels.orderBy(['position', 'id.length', 'id']);
+ }
+
+ getListsQuerySet() {
+ return this.lists.orderBy(['position', 'id.length', 'id']);
+ }
+
+ getKanbanListsQuerySet() {
+ return this.getListsQuerySet().filter((list) => isListKanban(list));
+ }
+
+ getCustomFieldGroupsQuerySet() {
+ return this.customFieldGroups.orderBy(['position', 'id.length', 'id']);
+ }
+
+ getActivitiesQuerySet() {
+ return this.activities.orderBy(['id.length', 'id'], ['desc', 'desc']);
+ }
+
+ getUnreadNotificationsQuerySet() {
+ return this.notifications.filter({
+ isRead: false,
+ });
+ }
+
+ getNotificationServicesQuerySet() {
+ return this.notificationServices.orderBy(['id.length', 'id']);
+ }
+
+ getMembershipModelByUserId(userId) {
+ return this.memberships
+ .filter({
+ userId,
+ })
+ .first();
+ }
+
+ getCardsModelArray() {
+ return this.getKanbanListsQuerySet()
+ .toModelArray()
+ .flatMap((listModel) => listModel.getCardsModelArray());
+ }
+
+ getFilteredCardsModelArray() {
+ let cardModels = this.getCardsModelArray();
+
+ if (cardModels.length === 0) {
+ return cardModels;
+ }
+
+ if (this.search) {
+ if (this.search.startsWith('/')) {
+ let searchRegex;
+ try {
+ searchRegex = new RegExp(this.search.substring(1), 'i');
+ } catch {
+ return [];
+ }
+
+ cardModels = cardModels.filter(
+ (cardModel) =>
+ searchRegex.test(cardModel.name) ||
+ (cardModel.description && searchRegex.test(cardModel.description)),
+ );
+ } else {
+ const searchParts = buildSearchParts(this.search);
+
+ cardModels = cardModels.filter((cardModel) => {
+ const name = cardModel.name.toLowerCase();
+ const description = cardModel.description && cardModel.description.toLowerCase();
+
+ return searchParts.every(
+ (searchPart) =>
+ name.includes(searchPart) || (description && description.includes(searchPart)),
+ );
+ });
+ }
+ }
+
+ const filterUserIds = this.filterUsers.toRefArray().map((user) => user.id);
+
+ if (filterUserIds.length > 0) {
+ cardModels = cardModels.filter((cardModel) => {
+ const users = cardModel.users.toRefArray();
+
+ if (users.some((user) => filterUserIds.includes(user.id))) {
+ return true;
+ }
+
+ return cardModel
+ .getTaskListsQuerySet()
+ .toModelArray()
+ .some((taskListModel) =>
+ taskListModel
+ .getTasksQuerySet()
+ .toRefArray()
+ .some((task) => task.assigneeUserId && filterUserIds.includes(task.assigneeUserId)),
+ );
+ });
+ }
+
+ const filterLabelIds = this.filterLabels.toRefArray().map((label) => label.id);
+ const filterExcludedLabelIds = this.filterExcludedLabels.toRefArray().map((label) => label.id);
+
+ if (filterLabelIds.length > 0 || filterExcludedLabelIds.length > 0) {
+ cardModels = filterCardLabels(cardModels, filterLabelIds, filterExcludedLabelIds);
+ }
+
+ // The three filters below are independent of one another and compose: a
+ // board can be narrowed to unassigned cards AND to a set of lists at the
+ // same time, so neither one may return early.
+ if (this.filterNoMember) {
+ cardModels = cardModels.filter((cardModel) => cardModel.users.toRefArray().length === 0);
+ }
+
+ const filterListIds = this.filterLists.toRefArray().map((list) => list.id);
+
+ if (filterListIds.length > 0) {
+ cardModels = cardModels.filter((cardModel) => filterListIds.includes(cardModel.listId));
+ }
+
+ return cardModels;
+ }
+
+ getActivitiesModelArray() {
+ if (this.isAllActivitiesFetched === null) {
+ return [];
+ }
+
+ const activityModels = this.getActivitiesQuerySet().toModelArray();
+
+ if (this.lastActivityId && this.isAllActivitiesFetched === false) {
+ return activityModels.filter((activityModel) => {
+ if (activityModel.id.length > this.lastActivityId.length) {
+ return true;
+ }
+
+ if (activityModel.id.length < this.lastActivityId.length) {
+ return false;
+ }
+
+ return activityModel.id >= this.lastActivityId;
+ });
+ }
+
+ return activityModels;
+ }
+
+ hasMembershipWithUserId(userId) {
+ return this.memberships
+ .filter({
+ userId,
+ })
+ .exists();
+ }
+
+ isAvailableForUser(userModel) {
+ if (!this.project) {
+ return false;
+ }
+
+ return (
+ this.project.isExternalAccessibleForUser(userModel) ||
+ this.hasMembershipWithUserId(userModel.id)
+ );
+ }
+
+ deleteListsWithRelated(soft) {
+ this.lists.toModelArray().forEach((listModel) => {
+ listModel.deleteWithRelated(soft);
+ });
+ }
+
+ deleteClearable() {
+ this.filterUsers.clear();
+ this.filterLabels.clear();
+ this.filterExcludedLabels.clear();
+ this.filterLists.clear();
+ }
+
+ deleteRelated(exceptMemberUserId, soft) {
+ this.deleteClearable();
+
+ this.memberships.toModelArray().forEach((boardMembershipModel) => {
+ if (boardMembershipModel.userId !== exceptMemberUserId) {
+ boardMembershipModel.deleteWithRelated();
+ }
+ });
+
+ this.labels.toModelArray().forEach((labelModel) => {
+ labelModel.deleteWithRelated();
+ });
+
+ this.deleteListsWithRelated(soft);
+ this.notificationServices.delete();
+ }
+
+ deleteWithClearable() {
+ this.deleteClearable();
+ this.delete();
+ }
+
+ deleteWithRelated(soft) {
+ this.deleteRelated(undefined, soft);
+ this.delete();
+ }
+}