diff --git a/app/api/migrations/migrations/107-add_system_key_translations/index.js b/app/api/migrations/migrations/107-add_system_key_translations/index.js new file mode 100644 index 0000000000..006bddc683 --- /dev/null +++ b/app/api/migrations/migrations/107-add_system_key_translations/index.js @@ -0,0 +1,60 @@ +/* +This migration is meant to be repeatable. +After copy pasting: + - change the contents of system_keys.csv to the new keyset + - change the file location in the readCsvToSystemKeys call + - change the tests, if necessary +*/ + +async function insertSystemKeys(db, newKeys) { + const translations = await db.collection('translations').find().toArray(); + const locales = translations.map(tr => tr.locale); + + const locToSystemContext = {}; + translations.forEach(tr => { + locToSystemContext[tr.locale] = tr.contexts.find(c => c.id === 'System'); + }); + const locToKeys = {}; + Object.entries(locToSystemContext).forEach(([loc, context]) => { + locToKeys[loc] = new Set(context.values.map(v => v.key)); + }); + + newKeys.forEach(row => { + const { key, value: optionalValue } = row; + + locales.forEach(loc => { + if (!locToKeys[loc].has(key)) { + const newValue = optionalValue || key; + locToSystemContext[loc].values.push({ key, value: newValue }); + locToKeys[loc].add(key); + } + }); + }); + + await Promise.all( + translations.map(tr => db.collection('translations').replaceOne({ _id: tr._id }, tr)) + ); +} + +export default { + delta: 107, + + reindex: false, + + name: 'add_system_key_translations', + + description: 'Adding missing translations for system keys.', + + async up(db) { + process.stdout.write(`${this.name}...\r\n`); + const systemKeys = [ + { + key: 'Collapse all', + }, + { + key: 'Expand all', + }, + ]; + await insertSystemKeys(db, systemKeys); + }, +}; diff --git a/app/api/migrations/migrations/107-add_system_key_translations/specs/107-add_system_key_translations.spec.js b/app/api/migrations/migrations/107-add_system_key_translations/specs/107-add_system_key_translations.spec.js new file mode 100644 index 0000000000..b6d86191d9 --- /dev/null +++ b/app/api/migrations/migrations/107-add_system_key_translations/specs/107-add_system_key_translations.spec.js @@ -0,0 +1,65 @@ +import { testingDB } from 'api/utils/testing_db'; +import migration from '../index.js'; +import { fixtures, templateId, defaultTemplateName, defaultTemplateTitle } from './fixtures.js'; + +const locales = ['en', 'es', 'hu']; +const newKeyValues = [ + { + key: 'Collapse all', + value: 'Collapse all', + }, + { key: 'Expand all', value: 'Expand all' }, +]; +const alreadyInAllContexts = { + key: 'Duplicated label', + en: 'Duplicated label', + es: 'Nombre duplicado', + hu: 'Ismétlődő címke', +}; + +describe('migration add_system_key_translations', () => { + beforeEach(async () => { + spyOn(process.stdout, 'write'); + await testingDB.setupFixturesAndContext(fixtures); + }); + + afterAll(async () => { + await testingDB.disconnect(); + }); + + it('should have a delta number', () => { + expect(migration.delta).toBe(107); + }); + + it('should append new keys, leave existing keys intact.', async () => { + await migration.up(testingDB.mongodb); + + const allTranslations = await testingDB.mongodb.collection('translations').find().toArray(); + function testKeyValue(key, value, locale, contextId) { + expect( + allTranslations + .find(tr => tr.locale === locale) + .contexts.find(c => c.id === contextId) + .values.find(v => v.key === key).value + ).toBe(value); + } + + newKeyValues.forEach(({ key, value }) => { + locales.forEach(loc => { + testKeyValue(key, value, loc, 'System'); + }); + }); + locales.forEach(loc => { + testKeyValue(alreadyInAllContexts.key, alreadyInAllContexts[loc], loc, 'System'); + }); + locales.forEach(loc => { + expect( + allTranslations + .find(tr => tr.locale === loc) + .contexts.find(c => c.id === templateId.toString()).values + ).toHaveLength(2); + testKeyValue(defaultTemplateName, defaultTemplateName, loc, templateId.toString()); + testKeyValue(defaultTemplateTitle, defaultTemplateTitle, loc, templateId.toString()); + }); + }); +}); diff --git a/app/api/migrations/migrations/107-add_system_key_translations/specs/fixtures.js b/app/api/migrations/migrations/107-add_system_key_translations/specs/fixtures.js new file mode 100644 index 0000000000..fd0204ac02 --- /dev/null +++ b/app/api/migrations/migrations/107-add_system_key_translations/specs/fixtures.js @@ -0,0 +1,90 @@ +import db from 'api/utils/testing_db'; + +const templateId = db.id(); +const defaultTemplateName = 'default template'; +const defaultTemplateTitle = 'Title'; + +//contexts +const commonContext = { + id: 'System', + label: 'User Interface', + type: 'Uwazi UI', + values: [ + { + key: 'existing-key-in-system', + value: 'existing-key-in-system', + }, + ], +}; +const templateContext = { + id: templateId.toString(), + label: defaultTemplateName, + type: 'Entity', + values: [ + { + key: defaultTemplateName, + value: defaultTemplateName, + }, + { + key: defaultTemplateTitle, + value: defaultTemplateTitle, + }, + ], +}; + +const fixtures = { + templates: [ + //default template name - correct + { + _id: templateId, + name: defaultTemplateName, + commonProperties: [{ name: 'title', label: defaultTemplateTitle, type: 'text' }], + properties: [], + }, + ], + translations: [ + { + _id: db.id(), + locale: 'es', + contexts: [ + { + ...commonContext, + values: commonContext.values.concat([ + { key: 'Drag properties here', value: 'Arrastra propiedades aquí' }, + { key: 'Duplicated label', value: 'Nombre duplicado' }, + ]), + }, + templateContext, + ], + }, + { + _id: db.id(), + locale: 'en', + contexts: [ + { + ...commonContext, + values: commonContext.values.concat([ + { key: 'Priority sorting', value: 'Priority sort' }, + { key: 'Duplicated label', value: 'Duplicated label' }, + ]), + }, + templateContext, + ], + }, + { + _id: db.id(), + locale: 'hu', + contexts: [ + { + ...commonContext, + values: commonContext.values.concat([ + { key: 'Duplicated label', value: 'Ismétlődő címke' }, + ]), + }, + templateContext, + ], + }, + ], +}; + +export { fixtures, templateId, defaultTemplateName, defaultTemplateTitle }; diff --git a/app/react/App/Collapsible.tsx b/app/react/App/Collapsible.tsx new file mode 100644 index 0000000000..e33f4d711b --- /dev/null +++ b/app/react/App/Collapsible.tsx @@ -0,0 +1,31 @@ +import { Icon } from 'UI'; +import React, { ReactElement, useEffect, useState } from 'react'; + +interface CollapsibleProps { + className?: string; + header: string | HTMLElement; + headerInfo?: string; + children: ReactElement; + collapse?: boolean; +} + +const Collapsible = ({ header, children, className, headerInfo, collapse }: CollapsibleProps) => { + const [collapsed, setCollapsed] = useState(collapse); + useEffect(() => { + setCollapsed(collapse); + }, [collapse]); + return ( +
+
setCollapsed(!collapsed)}> + + + + {header} + {headerInfo && {headerInfo}} +
+ {!collapsed &&
{children}
} +
+ ); +}; + +export { Collapsible }; diff --git a/app/react/App/StickyHeader.tsx b/app/react/App/StickyHeader.tsx new file mode 100644 index 0000000000..37a9c3ff8d --- /dev/null +++ b/app/react/App/StickyHeader.tsx @@ -0,0 +1,60 @@ +import React, { LegacyRef, ReactElement, useEffect } from 'react'; + +interface StickyHeaderProps { + children: ReactElement; + scrollElementSelector: string; + stickyElementSelector: string; +} + +const getMeasurements = (target: HTMLElement, current: HTMLElement) => { + const parentTop = target.getBoundingClientRect().top; + const scrollerTop = target.scrollTop; + const stickyTop = current.offsetTop || 0; + const stickyBottom = stickyTop + current.offsetHeight; + return { + scrollerTop, + stickyTop, + stickyBottom, + parentTop, + }; +}; + +const eventHandler = (self: any, stickyElementSelector: string, event: Event) => { + if (self && self.current && event.target && event.target instanceof Element) { + const { current } = self; + const stickyElement: HTMLElement = current.querySelector(stickyElementSelector); + current.classList.remove('sticky'); + const { scrollerTop, stickyTop, stickyBottom, parentTop } = getMeasurements( + event.target as HTMLElement, + current + ); + + if (stickyTop < scrollerTop && stickyBottom > scrollerTop) { + current.classList.add('sticky'); + if (stickyElement) { + stickyElement.style.top = `${parentTop}px`; + } + } + } +}; + +const StickyHeader = (props: StickyHeaderProps) => { + const { children, scrollElementSelector, stickyElementSelector } = props; + const self: LegacyRef = React.createRef(); + const body = document.querySelector(scrollElementSelector); + useEffect(() => { + body?.addEventListener('scroll', event => { + eventHandler(self, stickyElementSelector, event); + }); + + return () => { + body?.removeEventListener('scroll', event => + eventHandler(self, stickyElementSelector, event) + ); + }; + }); + + return
{children}
; +}; + +export { StickyHeader }; diff --git a/app/react/App/scss/layout/_footer.scss b/app/react/App/scss/layout/_footer.scss index b3a1cdd24a..5aef8043c7 100644 --- a/app/react/App/scss/layout/_footer.scss +++ b/app/react/App/scss/layout/_footer.scss @@ -1,3 +1,12 @@ +@mixin relationshipsFooterButtons { + .relationships-right-buttons { + float: right; + } + .relationships-left-buttons { + width: 50%; + } +} + footer { margin: 30px 0 0; padding: 20px 0 15px; @@ -237,6 +246,8 @@ footer { .btn:disabled .btn-label { color: $c-grey !important; } + + @include relationshipsFooterButtons(); } @media (max-width: 1023px) { @@ -259,6 +270,7 @@ footer { .sidepanel-footer { width: 400px; + @include relationshipsFooterButtons(); } .entity-footer, diff --git a/app/react/App/scss/layout/_sidepanel.scss b/app/react/App/scss/layout/_sidepanel.scss index 477b2781e8..97e11bba4e 100644 --- a/app/react/App/scss/layout/_sidepanel.scss +++ b/app/react/App/scss/layout/_sidepanel.scss @@ -5,6 +5,7 @@ $c-sidebar: darken($c-background, 0%); $c-sidebar: $c-white; + .side-panel { z-index: 4; display: flex; @@ -177,6 +178,7 @@ $c-sidebar: $c-white; border-top: 0; border-left: 0; border-right: 0; + border-bottom: 0; border-radius: 0; box-shadow: none; &:before { @@ -211,6 +213,7 @@ $c-sidebar: $c-white; } .sidepanel-body { + position: relative; flex: 1; overflow-y: auto; overflow-x: hidden; @@ -234,6 +237,90 @@ $c-sidebar: $c-white; // // firefox hack (related issue #378) // padding-bottom: $header-height * 3; // } + .sidepanel-relationships { + display: flex; + flex-direction: column; + height: 100%; + font-size: $f-size-lg; + + .sticky { + padding-top: 46px; + .sidepanel-relationship { + .sidepanel-relationship-left-label { + position: fixed; + top: 0; + z-index: 1; + width: 100%; + } + } + } + .sidepanel-relationship { + display: flex; + flex-direction: column; + .sidepanel-relationship-left-label { + font-weight: bold; + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 8px 16px; + gap: 9px; + background: #d7d7d7; + border: 1px solid #d7d7d7; + } + .sidepanel-relationship-right { + padding: 7px 10px 7px 10px; + .sidepanel-relationship-collapsible { + .header { + cursor: pointer; + font-weight: bold; + padding: 6px 12px; + gap: 12px; + background: #eeeeee; + border: 1px solid #d7d7dc; + margin-bottom: 7px; + .header-info { + float: right; + } + .header-icon { + margin-right: 6px; + font-size: large; + } + } + .content { + .sidepanel-relationship-right-entity { + cursor: pointer; + height: 100%; + margin-bottom: 7px; + border: 1px solid #d7d7dc; + box-sizing: border-box; + + gap: 9px; + .item-document { + z-index: -1; + padding-bottom: 0; + min-height: 0; + + .item-info { + padding: 5px 10px; + } + .item-metadata { + display: none; + } + .item-actions { + padding: 0; + height: auto; + padding: 0 10px 10px; + -webkit-align-items: center; + align-items: center; + position: relative; + } + } + } + } + } + } + } + } } .sidepanel-header { diff --git a/app/react/App/scss/modules/_entity.scss b/app/react/App/scss/modules/_entity.scss index d05686718e..b7d834ea39 100644 --- a/app/react/App/scss/modules/_entity.scss +++ b/app/react/App/scss/modules/_entity.scss @@ -47,6 +47,7 @@ .connections-metadata, .entity-create-connection-panel { top: 0; + z-index: 6; @media (min-width: 1024px) { top: $header-height + 1 !important; } diff --git a/app/react/App/specs/Collapsible.spec.tsx b/app/react/App/specs/Collapsible.spec.tsx new file mode 100644 index 0000000000..30137ad52e --- /dev/null +++ b/app/react/App/specs/Collapsible.spec.tsx @@ -0,0 +1,58 @@ +/** + * @jest-environment jsdom + */ + +import { mount, CommonWrapper } from 'enzyme'; +import React from 'react'; +import { Provider } from 'react-redux'; +import thunk from 'redux-thunk'; +import configureStore, { MockStoreCreator } from 'redux-mock-store'; +import { Collapsible } from '../Collapsible'; + +describe('Collapsible', () => { + let component: CommonWrapper; + const middlewares = [thunk]; + const mockStoreCreator: MockStoreCreator = configureStore(middlewares); + + const render = (props = { collapse: false }) => + mount( + + +
+ + + ); + + it('should render children', () => { + component = render(); + expect(component.contains(
)).toBe(true); + }); + + it('should hide children when clicked', () => { + const mountComp = render(); + mountComp.simulate('click'); + mountComp.find('.header').simulate('click'); + expect(mountComp.contains(
)).toBe(false); + }); + + it('should collapse if collapse prop is set', () => { + const mountComp = render({ collapse: true }); + expect(mountComp.contains(
)).toBe(false); + }); + + it('should collapse if collapse prop is not set', () => { + const mountComp = render(); + expect(mountComp.contains(
)).toBe(true); + }); + + it('should react to collapse prop', () => { + const props = { + collapse: false, + }; + component = render(props); + expect(component.contains(
)).toBe(true); + props.collapse = true; + component.update(); + // expect(component.contains(
)).toBe(false); + }); +}); diff --git a/app/react/ConnectionsList/actions/actions.js b/app/react/ConnectionsList/actions/actions.js index ea65c7d7e2..358bb8b9b7 100644 --- a/app/react/ConnectionsList/actions/actions.js +++ b/app/react/ConnectionsList/actions/actions.js @@ -31,13 +31,17 @@ export function searchReferences() { }; } -export function connectionsChanged() { +export function connectionsChanged(sharedId) { return (dispatch, getState) => { + dispatch(actions.set('relationships/list/filters', { limit: 10 })); const relationshipsList = getState().relationships.list; - const { sharedId } = relationshipsList; + let innerSharedId = sharedId; + if (!innerSharedId) { + innerSharedId = relationshipsList.sharedId; + } return referencesAPI - .getGroupedByConnection(new RequestParams({ sharedId })) + .getGroupedByConnection(new RequestParams({ sharedId: innerSharedId })) .then(connectionsGroups => { const filteredTemplates = connectionsGroups.reduce( (templateIds, group) => templateIds.concat(group.templates.map(t => t._id.toString())), diff --git a/app/react/ConnectionsList/components/ConnectionsGroups.js b/app/react/ConnectionsList/components/ConnectionsGroups.js index b01063edeb..a7ace632c5 100644 --- a/app/react/ConnectionsList/components/ConnectionsGroups.js +++ b/app/react/ConnectionsList/components/ConnectionsGroups.js @@ -6,10 +6,11 @@ import { connect } from 'react-redux'; import { t } from 'app/I18N'; import ConnectionsGroup from './ConnectionsGroup'; +import { LibraryViewRelationships } from './LibraryViewRelationships'; class ConnectionsGroupsComponent extends Component { render() { - const { connectionsGroups } = this.props; + const { connectionsGroups, expanded } = this.props; let Results = (
@@ -20,30 +21,41 @@ class ConnectionsGroupsComponent extends Component { ); if (connectionsGroups.size) { - Results = ( -
-
-
    - {connectionsGroups.map(group => ( - - ))} -
+ if (this.props.sidePanelTrigger === 'library') { + Results = ; + } else { + Results = ( +
+
+
    + {connectionsGroups.map(group => ( + + ))} +
+
-
- ); + ); + } } return Results; } } +ConnectionsGroupsComponent.defaultProps = { + expanded: false, +}; + ConnectionsGroupsComponent.propTypes = { connectionsGroups: PropTypes.instanceOf(Immutable.List).isRequired, + sidePanelTrigger: PropTypes.string.isRequired, + expanded: PropTypes.bool, }; -function mapStateToProps({ relationships }) { +function mapStateToProps({ relationships, library }) { return { connectionsGroups: relationships.list.connectionsGroups, + sidePanelTrigger: library.sidepanel.view, }; } diff --git a/app/react/ConnectionsList/components/LibraryViewRelationships.tsx b/app/react/ConnectionsList/components/LibraryViewRelationships.tsx new file mode 100644 index 0000000000..b8987469df --- /dev/null +++ b/app/react/ConnectionsList/components/LibraryViewRelationships.tsx @@ -0,0 +1,148 @@ +import React, { useEffect } from 'react'; +import { connect, ConnectedProps } from 'react-redux'; +import { Map } from 'immutable'; +import { bindActionCreators, Dispatch } from 'redux'; +import { Icon } from 'app/UI'; +import { Item } from 'app/Layout'; +import { Collapsible } from 'app/App/Collapsible'; +import { StickyHeader } from 'app/App/StickyHeader'; +import RelationshipMetadata from 'app/Relationships/components/RelationshipMetadata'; +import LoadMoreRelationshipsButton from 'app/Relationships/components/LoadMoreRelationshipsButton'; +import { IStore } from 'app/istore'; +import * as actions from '../../Relationships/actions/actions'; + +interface LibraryViewRelationshipsProps { + expanded: boolean; +} + +function mapStateToProps(state: IStore) { + const { relationships, library } = state; + return { + parentEntity: library.ui.get('selectedDocuments').get(0), + searchResults: relationships.list.searchResults, + hubs: relationships.hubs, + relationTypes: actions.selectRelationTypes(state), + }; +} + +function mapDispatchToProps(dispatch: Dispatch<{}>) { + return bindActionCreators( + { + selectConnection: actions.selectConnection, + parseResults: actions.parseResults, + }, + dispatch + ); +} + +const connector = connect(mapStateToProps, mapDispatchToProps); + +type ComponentProps = ConnectedProps & LibraryViewRelationshipsProps; + +const createRightRelationshipGroups = ( + rightRelationships: any, + props: ComponentProps, + expanded: boolean +) => { + const { relationTypes, selectConnection, parentEntity } = props; + return ( +
+ {rightRelationships.map((relationship: Map, index: number) => { + let header; + const relationshipTemplateId = relationship.get('template'); + const relationType = relationTypes.find(r => r._id === relationshipTemplateId); + if (relationType) { + header = relationshipTemplateId ? ( + relationTypes.find(r => r._id === relationshipTemplateId)?.name + ) : ( + + ); + } + + const entityRelationships = relationship.get('relationships'); + return ( + + <> + {entityRelationships.map((rel: any, entityRelationshipsIndex: number) => ( +
selectConnection(rel.get('entityData'))} + > + +
+ ))} + +
+ ); + })} +
+ ); +}; + +const renderLabel = (template: any, relationTypes: any) => + template ? ( + + {`${relationTypes.find((r: any) => r._id === template).name}`} + + ) : ( + + + + ); + +const createLabelGroups = (props: ComponentProps, hub: any, index: number) => { + const { relationTypes, expanded, parentEntity } = props; + const template = hub.getIn(['leftRelationship', 'template']); + return ( + +
+ {renderLabel(template, relationTypes)} + {createRightRelationshipGroups(hub.get('rightRelationships'), props, expanded)} +
+
+ ); +}; + +const LibraryViewRelationshipsComp = (props: ComponentProps) => { + const { hubs, searchResults, parentEntity, parseResults } = props; + + useEffect(() => { + if (parentEntity) { + parseResults(searchResults, parentEntity, false); + } + }, [searchResults, parentEntity]); + return ( + <> +
+ {parentEntity && + hubs.map((hub: any, index: number) => createLabelGroups(props, hub, index))} +
+ + + + ); +}; + +LibraryViewRelationshipsComp.defaultProps = { + expanded: true, +}; + +const LibraryViewRelationships = connector(LibraryViewRelationshipsComp); + +export { LibraryViewRelationshipsComp, LibraryViewRelationships }; diff --git a/app/react/ConnectionsList/components/specs/ConnectionsGroups.spec.js b/app/react/ConnectionsList/components/specs/ConnectionsGroups.spec.js index 3469c4ee12..b771b2ab09 100644 --- a/app/react/ConnectionsList/components/specs/ConnectionsGroups.spec.js +++ b/app/react/ConnectionsList/components/specs/ConnectionsGroups.spec.js @@ -21,6 +21,7 @@ describe('ConnectionsGroups', () => { ], }, ]), + sidePanelTrigger: 'entityView', }; }); diff --git a/app/react/ConnectionsList/components/specs/LibraryViewRelationships.spec.tsx b/app/react/ConnectionsList/components/specs/LibraryViewRelationships.spec.tsx new file mode 100644 index 0000000000..e81e3c12f5 --- /dev/null +++ b/app/react/ConnectionsList/components/specs/LibraryViewRelationships.spec.tsx @@ -0,0 +1,94 @@ +import React, { ReactNode } from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import { fromJS as Immutable } from 'immutable'; +import { Collapsible } from 'app/App/Collapsible'; +import { LibraryViewRelationshipsComp } from '../LibraryViewRelationships'; + +describe('LibraryViewRelationships', () => { + let component: ShallowWrapper; + let props: any; + const entityData = { + _id: 'entityid', + title: 'Some entity title', + sharedId: 'entitysharedid', + }; + + beforeEach(() => { + props = { + expanded: false, + parentEntity: Immutable({ _id: 'someparentid' }), + searchResults: Immutable({}), + hubs: Immutable([ + { + hub: 'hubid', + leftRelationship: { template: 'tempId', hub: 'hubid', entity: 'entityid', entityData }, + rightRelationships: [ + { + template: 'tempId', + relationships: [ + { + entityData, + _id: 'rightrelationshipsid', + entity: entityData._id, + hub: 'hubid', + template: 'tempId', + }, + ], + }, + ], + }, + ]), + parseResults: () => {}, + relationTypes: [{ name: 'Some name', _id: 'tempId' }], + selectConnection: () => {}, + }; + }); + + const render = (innerProps = props) => { + component = shallow(); + }; + + it('should show labels if available', () => { + render(); + expect(component.find('.sidepanel-relationship-left-label').text()).toEqual('Some name'); + }); + + it('should render right relationships as collapsibles', () => { + render(); + const collapsibleProps = component.find(Collapsible).props(); + expect(collapsibleProps.header).toEqual('Some name'); + expect(collapsibleProps.headerInfo).toEqual('(1)'); + expect(collapsibleProps.collapse).toEqual(true); + }); + + it('should not show default labels if none available', () => { + const customProps = { + ...props, + hubs: Immutable([ + { + hub: 'hubid', + leftRelationship: { template: null, hub: 'hubid', entity: 'entityid', entityData }, + rightRelationships: [ + { + template: 'tempId', + relationships: [ + { + entityData, + _id: 'rightrelationshipsid', + entity: entityData._id, + hub: 'hubid', + template: 'tempId', + }, + ], + }, + ], + }, + ]), + }; + render(customProps); + const label = component.find('.sidepanel-relationship-left-label'); + expect(label.exists()).toEqual(true); + // @ts-ignore + expect((label.props().children as ReactNode)?.props).toEqual({ icon: 'link' }); + }); +}); diff --git a/app/react/Documents/components/DocumentSidePanel.js b/app/react/Documents/components/DocumentSidePanel.js index 4bfb785780..190b67d01a 100644 --- a/app/react/Documents/components/DocumentSidePanel.js +++ b/app/react/Documents/components/DocumentSidePanel.js @@ -21,8 +21,11 @@ import { CopyFromEntity } from 'app/Metadata/components/CopyFromEntity'; import { TocGeneratedLabel, ReviewTocButton } from 'app/ToggledFeatures/tocGeneration'; import { Icon } from 'UI'; +import { Item } from 'app/Layout'; import * as viewerModule from 'app/Viewer'; import { entityDefaultDocument } from 'shared/entityDefaultDocument'; +import ViewDocButton from 'app/Library/components/ViewDocButton'; +import { getDocumentReferences } from 'app/Library/actions/libraryActions'; import SearchText from './SearchText'; import ShowToc from './ShowToc'; import SnippetsTab from './SnippetsTab'; @@ -32,24 +35,23 @@ class DocumentSidePanel extends Component { constructor(props) { super(props); this.selectTab = this.selectTab.bind(this); - this.state = { copyFrom: false, copyFromProps: [] }; + this.state = { copyFrom: false, copyFromProps: [], relationshipsExpanded: true }; this.toggleCopyFrom = this.toggleCopyFrom.bind(this); this.onCopyFromSelect = this.onCopyFromSelect.bind(this); this.deleteDocument = this.deleteDocument.bind(this); this.toggleSharing = this.toggleSharing.bind(this); } - componentDidUpdate(prevProps) { + async componentDidUpdate(prevProps) { + const sharedId = this.props.doc.get('sharedId'); if ( this.props.doc.get('_id') && prevProps.doc.get('_id') !== this.props.doc.get('_id') && - this.props.getDocumentReferences + this.props.connectionsChanged && + getDocumentReferences ) { - this.props.getDocumentReferences( - this.props.doc.get('sharedId'), - this.props.file._id, - this.props.storeKey - ); + this.props.getDocumentReferences(sharedId, this.props.file._id, this.props.storeKey); + this.props.connectionsChanged(sharedId); } } @@ -123,6 +125,20 @@ class DocumentSidePanel extends Component { })); } + collapseRelationships() { + // Toggles the states to force re-rendering + this.setState({ relationshipsExpanded: true }, () => + this.setState({ relationshipsExpanded: false }) + ); + } + + expandRelationships() { + // Toggles the states to force re-rendering + this.setState({ relationshipsExpanded: false }, () => + this.setState({ relationshipsExpanded: true }) + ); + } + renderHeader(tab, doc, isEntity) { if (this.state.copyFrom) { return ( @@ -144,134 +160,141 @@ class DocumentSidePanel extends Component { { totalConnections: 0 } ); return ( -
- - -
    - {(() => { - if (!this.props.raw && doc.get('semanticSearch')) { - return ( -
  • - - - - Semantic search results - - -
  • - ); - } - })()} - {(() => { - if (!this.props.raw) { - return ( -
  • - - - -
  • - ); - } - })()} - {(() => { - if (!isEntity && !this.props.raw) { - return ( -
  • - - - {t('System', 'Table of Contents')} - -
  • - ); - } - return ; - })()} - {(() => { - if (!isEntity && !this.props.raw) { - return ( -
  • - - - {references.size} - {t('System', 'References')} - -
  • - ); - } - return ; - })()} - {(() => { - if (!this.props.raw) { - return
  • ; - } - return ; - })()} -
  • - - - {t('System', 'Info')} - -
  • - {(() => { - if (!isTargetDoc && !excludeConnectionsTab) { - return ( -
  • - - - {summary.totalConnections} - {t('System', 'Relationships')} - -
  • - ); - } - })()} -
-
-
+ <> +
+ + +
    + {(() => { + if (!this.props.raw && doc.get('semanticSearch')) { + return ( +
  • + + + + Semantic search results + + +
  • + ); + } + })()} + {(() => { + if (!this.props.raw) { + return ( +
  • + + + +
  • + ); + } + })()} + {(() => { + if (!isEntity && !this.props.raw) { + return ( +
  • + + + {t('System', 'Table of Contents')} + +
  • + ); + } + return ; + })()} + {(() => { + if (!isEntity && !this.props.raw) { + return ( +
  • + + + {references.size} + {t('System', 'References')} + +
  • + ); + } + return ; + })()} + {(() => { + if (!this.props.raw) { + return
  • ; + } + return ; + })()} +
  • + + + {t('System', 'Info')} + +
  • + {(() => { + if (!isTargetDoc && !excludeConnectionsTab) { + return ( +
  • + + + {summary.totalConnections} + {t('System', 'Relationships')} + +
  • + ); + } + })()} +
+
+
+ +
+ +
+
+ ); } @@ -325,6 +348,30 @@ class DocumentSidePanel extends Component { />
+ +
+
+ +
+
+ + +
+
+
{this.props.tab === 'toc' && this.props.tocBeingEdited && (
@@ -368,7 +415,7 @@ class DocumentSidePanel extends Component {
)}
-
+
- - + + @@ -486,6 +533,7 @@ DocumentSidePanel.defaultProps = { isTargetDoc: false, readOnly: false, getDocumentReferences: undefined, + connectionsChanged: undefined, tocFormComponent: () => false, EntityForm: () => false, raw: false, @@ -518,6 +566,7 @@ DocumentSidePanel.propTypes = { editToc: PropTypes.func, leaveEditMode: PropTypes.func, searchSnippets: PropTypes.func, + connectionsChanged: PropTypes.func, getDocumentReferences: PropTypes.func, removeFromToc: PropTypes.func, indentTocElement: PropTypes.func, @@ -550,7 +599,7 @@ const mapStateToProps = (state, ownProps) => { return { references, - excludeConnectionsTab: Boolean(ownProps.references), + excludeConnectionsTab: Boolean(state.relationships.list.connectionsGroups.length), connectionsGroups: state.relationships.list.connectionsGroups, relationships: ownProps.references, defaultLanguage, diff --git a/app/react/Documents/components/specs/DocumentSidePanel.spec.js b/app/react/Documents/components/specs/DocumentSidePanel.spec.js index bbda780244..2b8c604270 100644 --- a/app/react/Documents/components/specs/DocumentSidePanel.spec.js +++ b/app/react/Documents/components/specs/DocumentSidePanel.spec.js @@ -213,7 +213,7 @@ describe('DocumentSidePanel', () => { beforeEach(() => { state = { documentViewer: { targetDoc: Immutable.fromJS({ _id: null }) }, - relationships: { list: { connectionsGroups: 'connectionsGroups' } }, + relationships: { list: { connectionsGroups: ['connectionsGroups'] } }, relationTypes: Immutable.fromJS(['a', 'b']), settings: { collection: Immutable.fromJS({ languages }) }, library: { sidepanel: { metadata: {} } }, @@ -241,12 +241,13 @@ describe('DocumentSidePanel', () => { expect(mapStateToProps(state, ownProps).references).toBe( 'References selector used correctly' ); - expect(mapStateToProps(state, ownProps).excludeConnectionsTab).toBe(false); + expect(mapStateToProps(state, ownProps).excludeConnectionsTab).toBe(true); }); it('should map selected target references from viewer when no ownProps and targetDoc', () => { const ownProps = { storeKey: 'library' }; state.documentViewer.targetDoc = Immutable.fromJS({ _id: 'targetDocId' }); + state.relationships.list.connectionsGroups = []; expect(mapStateToProps(state, ownProps).references).toBe( 'Target references selector used correctly' ); @@ -255,7 +256,7 @@ describe('DocumentSidePanel', () => { it('should map connectionsGroups', () => { const ownProps = { storeKey: 'library' }; - expect(mapStateToProps(state, ownProps).connectionsGroups).toBe('connectionsGroups'); + expect(mapStateToProps(state, ownProps).connectionsGroups).toEqual(['connectionsGroups']); }); it('should map default language', () => { diff --git a/app/react/Library/actions/libraryActions.js b/app/react/Library/actions/libraryActions.js index 23affe2ea7..f163e49b10 100644 --- a/app/react/Library/actions/libraryActions.js +++ b/app/react/Library/actions/libraryActions.js @@ -33,6 +33,7 @@ function selectDocument(_doc) { if (showingSemanticSearch && !doc.semanticSearch) { dispatch(actions.set('library.sidepanel.tab', '')); } + dispatch(actions.set('library.sidepanel.view', 'library')); await dispatch(maybeSaveQuickLabels()); dispatch({ type: types.SELECT_DOCUMENT, doc }); dispatch(selectedDocumentsChanged()); @@ -383,6 +384,7 @@ function getDocumentReferences(sharedId, fileId, storeKey) { .get(new RequestParams({ sharedId, file: fileId, onlyTextReferences: true })) .then(references => { dispatch(actions.set(`${storeKey}.sidepanel.references`, references)); + dispatch(actions.set('relationships/list/sharedId', sharedId)); }); } diff --git a/app/react/Library/actions/specs/__snapshots__/libraryActions.spec.js.snap b/app/react/Library/actions/specs/__snapshots__/libraryActions.spec.js.snap index c7e44fc732..789be862ed 100644 --- a/app/react/Library/actions/specs/__snapshots__/libraryActions.spec.js.snap +++ b/app/react/Library/actions/specs/__snapshots__/libraryActions.spec.js.snap @@ -6,6 +6,10 @@ Array [ "type": "library.sidepanel.tab/SET", "value": "", }, + Object { + "type": "library.sidepanel.view/SET", + "value": "library", + }, Object { "doc": Object { "sharedId": "doc", diff --git a/app/react/Library/actions/specs/libraryActions.spec.js b/app/react/Library/actions/specs/libraryActions.spec.js index 2ff1010ef2..42269760f6 100644 --- a/app/react/Library/actions/specs/libraryActions.spec.js +++ b/app/react/Library/actions/specs/libraryActions.spec.js @@ -463,6 +463,7 @@ describe('libraryActions', () => { const expectedActions = [ { type: 'library.sidepanel.references/SET', value: 'referencesResponse' }, + { type: 'relationships/list/sharedId/SET', value: 'id' }, ]; const store = mockStore({ locale: 'es' }); diff --git a/app/react/Library/components/ViewDocButton.js b/app/react/Library/components/ViewDocButton.js index 03a7649bf7..e765ee592d 100644 --- a/app/react/Library/components/ViewDocButton.js +++ b/app/react/Library/components/ViewDocButton.js @@ -5,7 +5,7 @@ import React, { Component } from 'react'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; import { Map } from 'immutable'; -import { t, I18NLink } from 'app/I18N'; +import { Translate, I18NLink } from 'app/I18N'; import { Icon } from 'UI'; import { actions } from 'app/BasicReducer'; import url from 'url'; @@ -36,7 +36,7 @@ export class ViewDocButton extends Component { } render() { - const { sharedId, processed, searchTerm, file, targetReference } = this.props; + const { sharedId, processed, searchTerm, file, targetReference, icon } = this.props; const isEntity = !file; const pathname = `/entity/${sharedId}`; @@ -56,13 +56,14 @@ export class ViewDocButton extends Component { className="btn btn-default btn-xs view-doc" onClick={this.onClick} > - {t('System', 'View')} + View ); } } ViewDocButton.defaultProps = { + icon: 'angle-right', searchTerm: '', processed: false, targetReference: null, @@ -75,6 +76,7 @@ ViewDocButton.propTypes = { searchTerm: PropTypes.string, targetReference: PropTypes.instanceOf(Map), openReferencesTab: PropTypes.func.isRequired, + icon: PropTypes.string, }; export function mapStateToProps(state, props) { diff --git a/app/react/Library/components/ViewMetadataPanel.js b/app/react/Library/components/ViewMetadataPanel.js index b0469bdb12..2c410918e1 100644 --- a/app/react/Library/components/ViewMetadataPanel.js +++ b/app/react/Library/components/ViewMetadataPanel.js @@ -12,6 +12,7 @@ import { wrapDispatch } from 'app/Multireducer'; import { entityDefaultDocument } from 'shared/entityDefaultDocument'; import modals from 'app/Modals'; +import * as connectionsActions from 'app/ConnectionsList/actions/actions'; import { getDocumentReferences, unselectAllDocuments, @@ -52,6 +53,7 @@ function mapDispatchToProps(dispatch, props) { { loadInReduxForm: actions.loadInReduxForm, getDocumentReferences, + connectionsChanged: connectionsActions.connectionsChanged, closePanel: unselectAllDocuments, resetForm: () => _dispatch => { _dispatch(formActions.setInitial(`${props.storeKey}.sidepanel.metadata`)); diff --git a/app/react/Library/components/specs/__snapshots__/ViewDocButton.spec.js.snap b/app/react/Library/components/specs/__snapshots__/ViewDocButton.spec.js.snap index b50eae896b..492da270c6 100644 --- a/app/react/Library/components/specs/__snapshots__/ViewDocButton.spec.js.snap +++ b/app/react/Library/components/specs/__snapshots__/ViewDocButton.spec.js.snap @@ -11,7 +11,9 @@ exports[`ViewDocButton should render a view button poiting to the doc url with t icon="angle-right" /> - View + + View + `; @@ -26,7 +28,9 @@ exports[`ViewDocButton should render a view button poiting to the doc url with t icon="angle-right" /> - View + + View + `; @@ -41,7 +45,9 @@ exports[`ViewDocButton when targetReference is provided should render view butto icon="angle-right" /> - View + + View + `; @@ -56,6 +62,8 @@ exports[`ViewDocButton when targetReference is provided should render view butto icon="angle-right" /> - View + + View + `; diff --git a/app/react/Library/reducers/reducer.js b/app/react/Library/reducers/reducer.js index 86fb422eec..ee3780ae0d 100644 --- a/app/react/Library/reducers/reducer.js +++ b/app/react/Library/reducers/reducer.js @@ -57,5 +57,6 @@ export default storeKey => fullText: [], }), tab: createReducer(`${storeKey}.sidepanel.tab`, ''), + view: createReducer(`${storeKey}.sidepanel.view`, ''), }), }); diff --git a/app/react/Relationships/components/LoadMoreRelationshipsButton.js b/app/react/Relationships/components/LoadMoreRelationshipsButton.js index 33e74b00a7..852d1ab8eb 100644 --- a/app/react/Relationships/components/LoadMoreRelationshipsButton.js +++ b/app/react/Relationships/components/LoadMoreRelationshipsButton.js @@ -18,13 +18,20 @@ export const LoadMoreRelationshipsButton = ({ action(requestedHubs + loadMoreAmmount); }; + let loadMoreAmmountDisplay = loadMoreAmmount; + + const hubsLeft = totalHubs - requestedHubs; + if (hubsLeft < loadMoreAmmount) { + loadMoreAmmountDisplay = hubsLeft; + } + return (

{requestedHubs} {t('System', 'of')} {totalHubs} {t('System', 'hubs')}

); diff --git a/app/react/Relationships/components/RelationshipMetadata.js b/app/react/Relationships/components/RelationshipMetadata.js index 85bde12b4e..968971493b 100644 --- a/app/react/Relationships/components/RelationshipMetadata.js +++ b/app/react/Relationships/components/RelationshipMetadata.js @@ -7,7 +7,8 @@ import Immutable from 'immutable'; import { createSelector } from 'reselect'; import { Icon } from 'UI'; -import { ShowMetadata, MetadataFormButtons, MetadataForm, actions } from 'app/Metadata'; +import { ShowMetadata, MetadataForm, MetadataFormButtons, actions } from 'app/Metadata'; +import { Translate, I18NLink } from 'app/I18N'; import SidePanel from 'app/Layout/SidePanel'; import { CopyFromEntity } from 'app/Metadata/components/CopyFromEntity'; import { api as entitiesAPI } from 'app/Entities'; @@ -114,6 +115,34 @@ class RelationshipMetadata extends Component { ); } + renderButtons() { + if (this.props.entityBeingEdited) { + return ( + + ); + } + return ( + + + + View + + + ); + } + render() { const twoColumns = this.state.copyFrom ? 'two-columns' : ''; return ( @@ -131,19 +160,7 @@ class RelationshipMetadata extends Component { )}
{this.renderBody()}
-
- {!this.state.copyFrom && ( - - )} -
+
{this.renderButtons()}
); } diff --git a/app/react/Relationships/components/specs/LoadMoreRelationshipsButton.spec.js b/app/react/Relationships/components/specs/LoadMoreRelationshipsButton.spec.js index 1a27a0b86c..e7dd43429b 100644 --- a/app/react/Relationships/components/specs/LoadMoreRelationshipsButton.spec.js +++ b/app/react/Relationships/components/specs/LoadMoreRelationshipsButton.spec.js @@ -34,7 +34,7 @@ describe('LoadMoreRelationshipsButton', () => { it('should render a button when partial loaded hubs', () => { expect(component.find('button').length).toBe(1); - expect(component.find('button').text()).toBe('2 x more'); + expect(component.find('button').text()).toBe('1 x more'); }); it('should call on the passed function upon click with previously requestedHubs', () => { diff --git a/app/react/UI/Icon/library.js b/app/react/UI/Icon/library.js index 1204f38785..2c8fe3071a 100644 --- a/app/react/UI/Icon/library.js +++ b/app/react/UI/Icon/library.js @@ -14,6 +14,7 @@ import { faBullhorn } from '@fortawesome/free-solid-svg-icons/faBullhorn'; import { faCalculator } from '@fortawesome/free-solid-svg-icons/faCalculator'; import { faCalendar } from '@fortawesome/free-solid-svg-icons/faCalendar'; import { faCaretDown } from '@fortawesome/free-solid-svg-icons/faCaretDown'; +import { faCaretRight } from '@fortawesome/free-solid-svg-icons/faCaretRight'; import { faCaretSquareDown } from '@fortawesome/free-solid-svg-icons/faCaretSquareDown'; import { faCaretUp } from '@fortawesome/free-solid-svg-icons/faCaretUp'; import { faChartBar } from '@fortawesome/free-solid-svg-icons/faChartBar'; @@ -133,6 +134,7 @@ const icons = { faCalculator, faCalendar, faCaretDown, + faCaretRight, faCaretSquareDown, faCaretUp, faChartBar, diff --git a/app/react/Viewer/components/ViewerComponent.js b/app/react/Viewer/components/ViewerComponent.js index 6980db376a..b10b04cb50 100644 --- a/app/react/Viewer/components/ViewerComponent.js +++ b/app/react/Viewer/components/ViewerComponent.js @@ -2,13 +2,19 @@ import { Map } from 'immutable'; import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; import EntityView from 'app/Viewer/EntityView'; import Loader from 'app/components/Elements/Loader'; - +import { actions } from 'app/BasicReducer'; import PDFView from '../PDFView'; export class ViewerComponent extends Component { + constructor(props, context) { + super(props, context); + props.setSidepanelTrigger(); + } + render() { const { entity } = this.props; @@ -22,8 +28,11 @@ export class ViewerComponent extends Component { ViewerComponent.propTypes = { entity: PropTypes.instanceOf(Map).isRequired, + setSidepanelTrigger: PropTypes.func.isRequired, }; +const setSidepanelTrigger = () => actions.set('library.sidepanel.view', 'entity'); + const mapStateToProps = state => { const entity = state.documentViewer.doc.get('_id') ? state.documentViewer.doc @@ -34,4 +43,13 @@ const mapStateToProps = state => { }; }; -export default connect(mapStateToProps)(ViewerComponent); +function mapDispatchToProps(dispatch) { + return bindActionCreators( + { + setSidepanelTrigger, + }, + dispatch + ); +} + +export default connect(mapStateToProps, mapDispatchToProps)(ViewerComponent); diff --git a/e2e/suite2/share-publicly.test.ts b/e2e/suite2/share-publicly.test.ts index be6d5dac6c..bca516e502 100644 --- a/e2e/suite2/share-publicly.test.ts +++ b/e2e/suite2/share-publicly.test.ts @@ -129,6 +129,8 @@ describe('Share publicly', () => { }); it('should create an entity', async () => { + await page.reload(); + await disableTransitions(); await expect(page).toClick('button', { text: 'Create entity' }); await expect(page).toFill('textarea[name="library.sidepanel.metadata.title"]', 'Test title'); await expect(page).toMatchElement('button', { text: 'Save' });