lp1789491 visited link color master
authorMike Risher <mrisher@catalyte.io>
Wed, 22 Jan 2020 22:56:42 +0000 (22:56 +0000)
committerJane Sandberg <sandbej@linnbenton.edu>
Thu, 27 Feb 2020 18:15:21 +0000 (10:15 -0800)
Some interfaces use a red or dark red background color for certain rows
and the visited link color makes it hard to read.  Adjust this color
so there is more contrast against the red background.

Signed-off-by: Mike Risher <mrisher@catalyte.io>
Signed-off-by: Jessica Woolford <jwoolford@biblio.org>
Signed-off-by: Jane Sandberg <sandbej@linnbenton.edu>
202 files changed:
Open-ILS/examples/action_trigger_filters.json.example
Open-ILS/examples/fm_IDL.xml
Open-ILS/examples/oils_sip.xml.example
Open-ILS/src/eg2/src/app/common.module.ts
Open-ILS/src/eg2/src/app/core/auth.service.ts
Open-ILS/src/eg2/src/app/core/net.service.ts
Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/catalog/bib-record.service.ts
Open-ILS/src/eg2/src/app/share/catalog/catalog-common.module.ts
Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts
Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts
Open-ILS/src/eg2/src/app/share/catalog/search-context.ts
Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts
Open-ILS/src/eg2/src/app/share/common-widgets.module.ts
Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/context-menu/context-menu.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/share/fm-editor/fm-editor.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid.ts
Open-ILS/src/eg2/src/app/share/util/can-deactivate.guard.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/local/admin-carousel.component.html
Open-ILS/src/eg2/src/app/staff/admin/local/admin-local-splash.component.html
Open-ILS/src/eg2/src/app/staff/admin/local/routing.module.ts
Open-ILS/src/eg2/src/app/staff/admin/local/survey/survey-edit.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/local/survey/survey-edit.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/local/survey/survey-routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/local/survey/survey.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/local/survey/survey.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/local/survey/survey.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/floating-group/edit-floating-group.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/floating-group/edit-floating-group.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/floating-group/floating-group-routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/floating-group/floating-group.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/floating-group/floating-group.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/floating-group/floating-group.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/server/org-unit.component.ts
Open-ILS/src/eg2/src/app/staff/admin/server/routing.module.ts
Open-ILS/src/eg2/src/app/staff/booking/booking.module.ts
Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.html
Open-ILS/src/eg2/src/app/staff/booking/create-reservation-dialog.component.ts
Open-ILS/src/eg2/src/app/staff/booking/pickup.component.ts
Open-ILS/src/eg2/src/app/staff/booking/return.component.ts
Open-ILS/src/eg2/src/app/staff/cat/authority/authority.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/authority/marc-edit.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/authority/marc-edit.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/authority/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/routing.module.ts
Open-ILS/src/eg2/src/app/staff/catalog/basket-actions.component.html
Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts
Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.html
Open-ILS/src/eg2/src/app/staff/catalog/cnbrowse/results.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.html
Open-ILS/src/eg2/src/app/staff/catalog/hold/hold.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/prefs.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/prefs.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/record/pagination.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/record/parts.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.html
Open-ILS/src/eg2/src/app/staff/catalog/record/record.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/resolver.service.ts
Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.css
Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html
Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/result/results.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts
Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html
Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/search-templates.component.ts
Open-ILS/src/eg2/src/app/staff/common.module.ts
Open-ILS/src/eg2/src/app/staff/nav.component.html
Open-ILS/src/eg2/src/app/staff/nav.component.ts
Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html
Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts
Open-ILS/src/eg2/src/app/staff/share/bib-summary/bib-summary.component.html
Open-ILS/src/eg2/src/app/staff/share/buckets/bucket-dialog.component.html
Open-ILS/src/eg2/src/app/staff/share/holdings/delete-volcopy-dialog.component.html
Open-ILS/src/eg2/src/app/staff/share/holdings/delete-volcopy-dialog.component.ts
Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.html
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor.component.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.css [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.html
Open-ILS/src/eg2/src/app/staff/share/marc-edit/flat-editor.component.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/phys-char-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/phys-char-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css
Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/op-change/op-change.component.ts
Open-ILS/src/eg2/src/app/staff/share/patron.service.ts [deleted file]
Open-ILS/src/eg2/src/app/staff/share/patron/patron.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/patron.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/profile-select.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/search-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/search.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/patron/search.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/splash.component.html
Open-ILS/src/eg2/src/app/staff/staff.component.html
Open-ILS/src/eg2/src/assets/js/marcrecord.js
Open-ILS/src/eg2/src/styles.css
Open-ILS/src/extras/ils_events.xml
Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/Authority.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Holds.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Circ/Survey.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/CDBI.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/CDBI/money.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Driver/Pg/dbi.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/Publisher/money.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Trigger/Event.pm
Open-ILS/src/perlmods/lib/OpenILS/Reporter/SQLBuilder.pm
Open-ILS/src/perlmods/lib/OpenILS/SIP/Patron.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Account.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Search.pm
Open-ILS/src/sql/Pg/002.schema.config.sql
Open-ILS/src/sql/Pg/080.schema.money.sql
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/t/lp1849736_at_email_self_register.pg [new file with mode: 0755]
Open-ILS/src/sql/Pg/upgrade/1198.data.catalog-prefs.sql [new file with mode: 0644]
Open-ILS/src/sql/Pg/upgrade/1199.lp1849736_at_email_self_register.sql [new file with mode: 0755]
Open-ILS/src/sql/Pg/upgrade/1200.schema.debit_card_payment.sql [new file with mode: 0644]
Open-ILS/src/sql/Pg/version-upgrade/3.3.5-3.3.6-upgrade-db.sql [new file with mode: 0644]
Open-ILS/src/sql/Pg/version-upgrade/3.4.1-3.4.2-upgrade-db.sql [new file with mode: 0644]
Open-ILS/src/templates/opac/myopac/holds.tt2
Open-ILS/src/templates/opac/myopac/holds/edit.tt2
Open-ILS/src/templates/opac/myopac/prefs_notify.tt2
Open-ILS/src/templates/opac/myopac/prefs_notify_changed_holds.tt2 [new file with mode: 0644]
Open-ILS/src/templates/opac/parts/hold_notify.tt2 [new file with mode: 0644]
Open-ILS/src/templates/opac/parts/sms_carrier_selector.tt2
Open-ILS/src/templates/staff/admin/local/circ/neg_balance_users.tt2
Open-ILS/src/templates/staff/base_js.tt2
Open-ILS/src/templates/staff/cat/bucket/copy/t_view.tt2
Open-ILS/src/templates/staff/cat/catalog/t_holdings.tt2
Open-ILS/src/templates/staff/cat/item/index.tt2
Open-ILS/src/templates/staff/cat/item/t_circs_pane.tt2
Open-ILS/src/templates/staff/cat/item/t_list.tt2
Open-ILS/src/templates/staff/cat/item/t_summary_pane.tt2
Open-ILS/src/templates/staff/cat/share/t_authority_browser.tt2
Open-ILS/src/templates/staff/cat/share/t_authority_linker.tt2
Open-ILS/src/templates/staff/circ/checkin/t_checkin.tt2
Open-ILS/src/templates/staff/circ/holds/index.tt2
Open-ILS/src/templates/staff/circ/patron/t_bills.tt2
Open-ILS/src/templates/staff/circ/patron/t_edit.tt2
Open-ILS/src/templates/staff/circ/patron/t_hold_notify_update.tt2 [new file with mode: 0644]
Open-ILS/src/templates/staff/circ/patron/t_holds_list.tt2
Open-ILS/src/templates/staff/circ/patron/t_items_out.tt2
Open-ILS/src/templates/staff/circ/share/t_mark_damaged.tt2
Open-ILS/src/templates/staff/circ/transits/t_list.tt2
Open-ILS/src/templates/staff/css/style.css.tt2
Open-ILS/src/templates/staff/index.tt2
Open-ILS/src/templates/staff/navbar.tt2
Open-ILS/src/templates/staff/share/print_templates/t_bill_payment.tt2
Open-ILS/src/templates/staff/t_login.tt2
Open-ILS/src/templates/staff/t_splash.tt2
Open-ILS/tests/datasets/sql/surveys.sql
Open-ILS/web/js/ui/default/opac/simple.js
Open-ILS/web/js/ui/default/staff/app.js
Open-ILS/web/js/ui/default/staff/cat/catalog/app.js
Open-ILS/web/js/ui/default/staff/cat/item/app.js
Open-ILS/web/js/ui/default/staff/cat/services/holdings.js
Open-ILS/web/js/ui/default/staff/cat/services/marcedit.js
Open-ILS/web/js/ui/default/staff/circ/holds/app.js
Open-ILS/web/js/ui/default/staff/circ/patron/bills.js
Open-ILS/web/js/ui/default/staff/circ/patron/items_out.js
Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js
Open-ILS/web/js/ui/default/staff/circ/services/circ.js
Open-ILS/web/js/ui/default/staff/circ/services/item.js
Open-ILS/web/js/ui/default/staff/marcrecord.js
Open-ILS/web/js/ui/default/staff/reporter/template/app.js
Open-ILS/web/js/ui/default/staff/services/grid.js
Open-ILS/web/opac/common/js/fm_table_conf.js
docs/RELEASE_NOTES_NEXT/Administration/lp1849736_at_email_self_register.adoc [new file with mode: 0755]
docs/RELEASE_NOTES_NEXT/Cataloging/ang-marc-editor.adoc [new file with mode: 0644]
docs/RELEASE_NOTES_NEXT/Circulation/ang-cat-holds-patron-search.adoc [new file with mode: 0644]
docs/RELEASE_NOTES_NEXT/Circulation/update-hold-notifications.adoc [new file with mode: 0644]
docs/RELEASE_NOTES_NEXT/Client/ang-cat-prefs-page.adoc [new file with mode: 0644]
docs/RELEASE_NOTES_NEXT/Client/staff-cat-highlighting.adoc [new file with mode: 0644]
docs/RELEASE_NOTES_NEXT/SIP/Add_patron_status_always_permit_loans_SIP_option.adoc [new file with mode: 0644]

index 01f913d..c56559f 100644 (file)
             "active":"t",
             "deleted":"f"
         }
+    },
+    "stgu.created" : {
+        "context_org": "home_ou",
+        "filter": {
+            "complete": "f"
+        }
     }
 }
index 75b52a7..7622efd 100644 (file)
@@ -94,6 +94,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
                        <field name="cash_payment" oils_persist:virtual="true" />
                        <field name="check_payment" oils_persist:virtual="true" />
                        <field name="credit_card_payment" oils_persist:virtual="true" />
+                       <field name="debit_card_payment" oils_persist:virtual="true" />
                </fields>
                <links>
                        <link field="workstation" reltype="has_a" key="id" map="" class="aws"/>
@@ -7462,6 +7463,33 @@ SELECT  usr,
                        </actions>
                </permacrud>
        </class>
+       <class id="mdcp" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="money::debit_card_payment" oils_persist:tablename="money.debit_card_payment" reporter:label="Debit Card Payment">
+               <fields oils_persist:primary="id" oils_persist:sequence="money.payment_id_seq">
+                       <field reporter:label="Accepting Staff Member" name="accepting_usr" reporter:datatype="link"/>
+                       <field reporter:label="Amount" name="amount" reporter:datatype="money" />
+                       <field reporter:label="Amount Collected" name="amount_collected" reporter:datatype="money" />
+                       <field reporter:label="Workstation link" name="cash_drawer" reporter:datatype="link"/>
+                       <field reporter:label="Payment ID" name="id" reporter:datatype="id" />
+                       <field reporter:label="Note" name="note" reporter:datatype="text"/>
+                       <field reporter:label="Payment Timestamp" name="payment_ts" reporter:datatype="timestamp"/>
+                       <field reporter:label="Transaction link" name="xact" reporter:datatype="link"/>
+                       <field reporter:label="Payment link" name="payment" oils_persist:virtual="true" reporter:datatype="link"/>
+                       <field reporter:label="Payment Type" name="payment_type" oils_persist:virtual="true" reporter:datatype="text"/>
+               </fields>
+               <links>
+                       <link field="payment" reltype="might_have" key="id" map="" class="mp"/>
+                       <link field="accepting_usr" reltype="has_a" key="id" map="" class="au"/>
+                       <link field="cash_drawer" reltype="has_a" key="id" map="" class="aws"/>
+                       <link field="xact" reltype="has_a" key="id" map="" class="mbt"/>
+               </links>
+        <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+            <actions>
+                <retrieve permission="VIEW_USER_TRANSACTIONS">
+                    <context link="xact" jump="usr" field="home_ou"/>
+                </retrieve>
+                       </actions>
+               </permacrud>
+       </class>
        <class id="acp" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="asset::copy" oils_persist:tablename="asset.copy" reporter:core="true" reporter:label="Item">
                <fields oils_persist:primary="id" oils_persist:sequence="asset.copy_id_seq">
                        <field reporter:label="Age Hold Protection" name="age_protect" reporter:datatype="link"/>
@@ -8054,6 +8082,7 @@ SELECT  usr,
                        <field reporter:label="Forgive Payment Detail" name="forgive_payment" oils_persist:virtual="true" reporter:datatype="link"/>
                        <field reporter:label="Goods Payment Detail" name="goods_payment" oils_persist:virtual="true" reporter:datatype="link"/>
                        <field reporter:label="Account Adjustment Detail" name="account_adjustment" oils_persist:virtual="true" reporter:datatype="link"/>
+                       <field reporter:label="Debit Card Payment Detail" name="debit_card_payment" oils_persist:virtual="true" reporter:datatype="link"/>
                </fields>
                <links>
                        <link field="cash_payment" reltype="might_have" key="id" map="" class="mcp"/>
@@ -8065,6 +8094,7 @@ SELECT  usr,
                        <link field="goods_payment" reltype="might_have" key="id" map="" class="mgp"/>
                        <link field="account_adjustment" reltype="might_have" key="id" map="" class="maa"/>
                        <link field="xact" reltype="has_a" key="id" map="" class="mbt"/>
+                       <link field="debit_card_payment" reltype="might_have" key="id" map="" class="mdcp"/>
                </links>
         <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
             <actions>
@@ -8136,6 +8166,7 @@ SELECT  usr,
                        <field reporter:label="Forgive Payment Detail" name="forgive_payment" oils_persist:virtual="true" reporter:datatype="link"/>
                        <field reporter:label="Goods Payment Detail" name="goods_payment" oils_persist:virtual="true" reporter:datatype="link"/>
                        <field reporter:label="Account Adjustment Detail" name="account_adjustment" oils_persist:virtual="true" reporter:datatype="link"/>
+                       <field reporter:label="Debit Card Payment Detail" name="debit_card_payment" oils_persist:virtual="true" reporter:datatype="link"/>
                </fields>
                <links>
                        <link field="cash_payment" reltype="might_have" key="id" map="" class="mcp"/>
@@ -8148,6 +8179,7 @@ SELECT  usr,
                        <link field="account_adjustment" reltype="might_have" key="id" map="" class="maa"/>
                        <link field="xact" reltype="has_a" key="id" map="" class="mbt"/>
                        <link field="accepting_usr" reltype="has_a" key="id" map="" class="au"/>
+                       <link field="debit_card_payment" reltype="might_have" key="id" map="" class="mdcp"/>
                </links>
                <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
                        <actions>
@@ -8195,6 +8227,7 @@ SELECT  usr,
                        <field reporter:label="Cash Payment" name="cash_payment" oils_persist:virtual="true" reporter:datatype="link"/>
                        <field reporter:label="Credit Card Payment" name="credit_card_payment" oils_persist:virtual="true" reporter:datatype="link"/>
                        <field reporter:label="Check Payment" name="check_payment" oils_persist:virtual="true" reporter:datatype="link"/>
+                       <field reporter:label="Debit Card Payment" name="debit_card_payment" oils_persist:virtual="true" reporter:datatype="link"/>
                </fields>
                <links>
                        <link field="cash_payment" reltype="might_have" key="id" map="" class="mcp"/>
@@ -8203,6 +8236,7 @@ SELECT  usr,
                        <link field="xact" reltype="has_a" key="id" map="" class="mbt"/>
                        <link field="accepting_usr" reltype="has_a" key="id" map="" class="au"/>
                        <link field="cash_drawer" reltype="has_a" key="id" map="" class="aws"/>
+                       <link field="debit_card_payment" reltype="might_have" key="id" map="" class="mdcp"/>
                </links>
        </class>
        <class id="cbrebi" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="container::biblio_record_entry_bucket_item" oils_persist:tablename="container.biblio_record_entry_bucket_item" reporter:label="Biblio Record Entry Bucket Item">
@@ -10742,6 +10776,7 @@ SELECT  usr,
         </fields>
         <links>
             <link field="requesting_usr" reltype="has_a" key="id" map="" class="au"/>
+            <link field="home_ou" reltype="has_a" key="id" map="" class="aou"/>
         </links>
     </class>
 
@@ -12755,8 +12790,8 @@ SELECT  usr,
        <class id="cfg" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="config::floating_group" oils_persist:tablename="config.floating_group" reporter:label="Floating Group">
                <fields oils_persist:primary="id" oils_persist:sequence="config.floating_group_id_seq">
                        <field reporter:label="ID" name="id" reporter:selector="name" reporter:datatype="id"/>
-                       <field reporter:label="Name" name="name" reporter:datatype="text"/>
-                       <field reporter:label="Manual" name="manual" reporter:datatype="bool"/>
+                       <field reporter:label="Name" name="name" reporter:datatype="text" oils_obj:required="true"/>
+                       <field reporter:label="Manual" name="manual" reporter:datatype="bool" oils_obj:required="true"/>
                        <field reporter:label="Group Members" name="members" oils_persist:virtual="true" reporter:datatype="link"/>
                </fields>
                <links>
@@ -12774,11 +12809,11 @@ SELECT  usr,
        <class id="cfgm" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="config::floating_group_member" oils_persist:tablename="config.floating_group_member" reporter:label="Floating Group Members">
                <fields oils_persist:primary="id" oils_persist:sequence="config.floating_group_member_id_seq">
                        <field reporter:label="ID" name="id" reporter:datatype="id"/>
-                       <field reporter:label="Floating Group" name="floating_group" reporter:datatype="link"/>
-                       <field reporter:label="Org Unit" name="org_unit" reporter:datatype="link"/>
-                       <field reporter:label="Stop Depth" name="stop_depth" reporter:datatype="int"/>
+                       <field reporter:label="Floating Group" name="floating_group" reporter:datatype="link" oils_obj:required="true"/>
+                       <field reporter:label="Org Unit" name="org_unit" reporter:datatype="link" oils_obj:required="true"/>
+                       <field reporter:label="Stop Depth" name="stop_depth" reporter:datatype="int" oils_obj:required="true"/>
                        <field reporter:label="Max Depth" name="max_depth" reporter:datatype="int"/>
-                       <field reporter:label="Exclude" name="exclude" reporter:datatype="bool"/>
+                       <field reporter:label="Exclude" name="exclude" reporter:datatype="bool" oils_obj:required="true"/>
                </fields>
                <links>
                        <link field="floating_group" reltype="has_a" key="id" class="cfg"/>
@@ -13019,10 +13054,10 @@ SELECT  usr,
                </links>
                <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
                        <actions>
-                               <create permission="ADMIN_CAROUSEL" global_required="true"/>
-                               <retrieve/>
-                               <update permission="ADMIN_CAROUSEL" global_required="true"/>
-                               <delete permission="ADMIN_CAROUSEL" global_required="true"/>
+                               <create permission="ADMIN_CAROUSEL" context_field="owner"/>
+                               <retrieve permission="ADMIN_CAROUSEL" context_field="owner"/>
+                               <update permission="ADMIN_CAROUSEL" context_field="owner"/>
+                               <delete permission="ADMIN_CAROUSEL" context_field="owner"/>
                        </actions>
                </permacrud>
        </class>
@@ -13045,10 +13080,18 @@ SELECT  usr,
                </links>
                <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
                        <actions>
-                               <create permission="ADMIN_CAROUSEL" global_required="true"/>
-                               <retrieve/>
-                               <update permission="ADMIN_CAROUSEL" global_required="true"/>
-                               <delete permission="ADMIN_CAROUSEL" global_required="true"/>
+                               <create permission="ADMIN_CAROUSEL">
+                                   <context link="carousel" field="owner" />
+                               </create>
+                               <retrieve permission="ADMIN_CAROUSEL">
+                                   <context link="carousel" field="owner" />
+                               </retrieve>
+                               <update permission="ADMIN_CAROUSEL">
+                                   <context link="carousel" field="owner" />
+                               </update>
+                               <delete permission="ADMIN_CAROUSEL">
+                                   <context link="carousel" field="owner" />
+                               </delete>
                        </actions>
                </permacrud>
        </class>
index 06da91b..f0edf88 100644 (file)
                Specify which treatment you want in the av_format attribute.
                For example: <login id="sc" password="pwd" institution="main" av_format="3m">
                -->
+               <!--
+               The login attribute patron_status_always_permit_loans specifies whether
+               the charge privileges denied, renewal privilges denied, and
+               card reported lost flags in the patron status block should be
+               coerced to permissive values regardless of the actual state
+               of the patron record. Turning this on works around an issue
+               where a 2019-12 change by the Hoopla SIP2 client takes those flag
+               fields into account, but some libraries may not wish those
+               to block a patron's access to online resources that use
+               SIP2 to authenticate. This setting can also be set as
+               an implementation_config option; note that if it is set to
+               'true' or 'false' as a login attribute, the login attribute will
+               override whatever is set in the implementation_config.
+               -->
                <login id="scclient" password="clientpwd" institution="gapines"/>
        </accounts>
 
                                        <option name='patron_calculate_recal_ok' value='true' />
                                        -->
 
+                                       <!-- see description of patron_status_always_permit_loans in the login section -->
+                                       <!--
+                                       <option name='patron_status_always_permit_loans' value='false' />
+                                       -->
+
                                </options>
 
                 <checkin_override>
index 56eddab..5250670 100644 (file)
@@ -13,9 +13,9 @@ Note core services are injected into 'root'.
 They do not have to be added to the providers list.
 */
 
-// consider moving these to core...
 import {HtmlToTxtService} from '@eg/share/util/htmltotxt.service';
 import {PrintService} from '@eg/share/print/print.service';
+import {AnonCacheService} from '@eg/share/util/anon-cache.service';
 
 // Globally available components
 import {PrintComponent} from '@eg/share/print/print.component';
@@ -79,6 +79,7 @@ export class EgCommonModule {
         return {
             ngModule: EgCommonModule,
             providers: [
+                AnonCacheService,
                 HtmlToTxtService,
                 PrintService,
                 ToastService
index a173d63..9ad471f 100644 (file)
@@ -130,6 +130,11 @@ export class AuthService {
         let service = 'open-ils.auth';
         let method = 'open-ils.auth.login';
 
+        if (isOpChange && this.opChangeIsActive()) {
+            // Enforce one op-change at a time.
+            this.undoOpChange();
+        }
+
         return this.net.request(
             'open-ils.auth_proxy',
             'open-ils.auth_proxy.enabled')
index dd2bebb..42dae19 100644 (file)
@@ -73,7 +73,7 @@ export class NetService {
     permFailed$: EventEmitter<NetRequest>;
     authExpired$: EventEmitter<AuthExpiredEvent>;
 
-    // If true, permission failures are emitted via permFailed$
+    // If true, permission failures are emitted via permFailed
     // and the active request is marked as superseded.
     permFailedHasHandler: Boolean = false;
 
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.css b/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.css
new file mode 100644 (file)
index 0000000..f4dfc11
--- /dev/null
@@ -0,0 +1,11 @@
+
+.oils_SH {
+  font-weight: bolder;
+  background-color: #99ff99;
+}
+
+.oils_SH.identifier {
+  font-weight: bolder;
+  background-color: #42b0f4;
+}
+
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.html b/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.html
new file mode 100644 (file)
index 0000000..021e451
--- /dev/null
@@ -0,0 +1,7 @@
+
+<ng-container 
+  *ngFor="let val of getDisplayStrings(); let first = first">
+  <ng-container *ngIf="joiner && !first">{{joiner}} </ng-container>
+  <span [innerHTML]="val"></span>
+</ng-container>
+
diff --git a/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.ts b/Open-ILS/src/eg2/src/app/share/catalog/bib-display-field.component.ts
new file mode 100644 (file)
index 0000000..abcbb46
--- /dev/null
@@ -0,0 +1,62 @@
+import {Component, OnInit, Input, ViewEncapsulation} from '@angular/core';
+import {NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {BibRecordService, BibRecordSummary
+    } from '@eg/share/catalog/bib-record.service';
+
+/* Display content from a bib summary display field.  If highlight
+ * data is avaialble, it will be used in lieu of the plan display string.
+ *
+ * <eg-bib-display-field field="title" [summary]="summary"
+ *  [usePlaceholder]="true"></eg-bib-display-field>
+ */
+
+// non-collapsing space
+const PAD_SPACE = ' '; // U+2007
+
+@Component({
+  selector: 'eg-bib-display-field',
+  templateUrl: 'bib-display-field.component.html',
+  styleUrls: ['bib-display-field.component.css'],
+  encapsulation: ViewEncapsulation.None // required for search highlighting
+})
+export class BibDisplayFieldComponent implements OnInit {
+
+    @Input() summary: BibRecordSummary;
+    @Input() field: string; // display field name
+
+    // Used to join multi fields
+    @Input() joiner: string;
+
+    // If true, replace empty values with a non-collapsing space.
+    @Input() usePlaceholder: boolean;
+
+    constructor() {}
+
+    ngOnInit() {}
+
+    // Returns an array of display values which may either be
+    // plain string values or strings with embedded HTML markup
+    // for search results highlighting.
+    getDisplayStrings(): string[] {
+        const replacement = this.usePlaceholder ? PAD_SPACE : '';
+
+        if (!this.summary) { return [replacement]; }
+
+        const scrunch = (value) => {
+            if (Array.isArray(value)) {
+                return value;
+            } else {
+                return [value || replacement];
+            }
+        };
+
+        return scrunch(
+            this.summary.displayHighlights[this.field] ||
+            this.summary.display[this.field]
+        );
+    }
+}
+
+
index b2058e7..83d66c0 100644 (file)
@@ -31,6 +31,7 @@ export class BibRecordSummary {
     holdCount: number;
     bibCallNumber: string;
     net: NetService;
+    displayHighlights: {[name: string]: string | string[]} = {};
 
     constructor(record: IdlObject, orgId: number, orgDepth: number) {
         this.id = Number(record.id());
index 5b45d00..ba8c915 100644 (file)
@@ -1,31 +1,32 @@
 import {NgModule} from '@angular/core';
 import {EgCommonModule} from '@eg/common.module';
 import {CatalogService} from './catalog.service';
-import {AnonCacheService} from '@eg/share/util/anon-cache.service';
 import {BasketService} from './basket.service';
 import {CatalogUrlService} from './catalog-url.service';
 import {BibRecordService} from './bib-record.service';
 import {UnapiService} from './unapi.service';
 import {MarcHtmlComponent} from './marc-html.component';
+import {BibDisplayFieldComponent} from './bib-display-field.component';
 
 
 @NgModule({
     declarations: [
-        MarcHtmlComponent
+        MarcHtmlComponent,
+        BibDisplayFieldComponent
     ],
     imports: [
         EgCommonModule
     ],
     exports: [
-        MarcHtmlComponent
+        MarcHtmlComponent,
+        BibDisplayFieldComponent
     ],
     providers: [
-        AnonCacheService,
         CatalogService,
         CatalogUrlService,
         UnapiService,
         BibRecordService,
-        BasketService,
+        BasketService
     ]
 })
 
index 4c45a4e..7b9698f 100644 (file)
@@ -126,6 +126,7 @@ export class CatalogUrlService {
         if (context.cnBrowseSearch.isSearchable()) {
             params.cnBrowseTerm = context.cnBrowseSearch.value;
             params.cnBrowsePage = context.cnBrowseSearch.offset;
+            params.cnBrowsePageSize = context.cnBrowseSearch.limit;
         }
 
         return params;
@@ -198,6 +199,7 @@ export class CatalogUrlService {
         if (params.has('cnBrowseTerm')) {
             context.cnBrowseSearch.value = params.get('cnBrowseTerm');
             context.cnBrowseSearch.offset = Number(params.get('cnBrowsePage'));
+            context.cnBrowseSearch.limit = Number(params.get('cnBrowsePageSize'));
         }
 
         const ts = context.termSearch;
index 2aaaf1f..c80d0b2 100644 (file)
@@ -135,20 +135,18 @@ export class CatalogService {
             method += '.staff';
         }
 
-        return new Promise((resolve, reject) => {
-            this.net.request(
-                'open-ils.search', method, {
-                    limit : ctx.pager.limit + 1,
-                    offset : ctx.pager.offset
-                }, fullQuery, true
-            ).subscribe(result => {
-                this.applyResultData(ctx, result);
-                ctx.searchState = CatalogSearchState.COMPLETE;
-                this.onSearchComplete.emit(ctx);
-                resolve();
-            });
+        return this.net.request(
+            'open-ils.search', method, {
+                limit : ctx.pager.limit + 1,
+                offset : ctx.pager.offset
+            }, fullQuery, true
+        ).toPromise()
+        .then(result => this.applyResultData(ctx, result))
+        .then(_ => this.fetchFieldHighlights(ctx))
+        .then(_ => {
+            ctx.searchState = CatalogSearchState.COMPLETE;
+            this.onSearchComplete.emit(ctx);
         });
-
     }
 
     // When showing titles linked to a browse entry, fetch
@@ -212,6 +210,67 @@ export class CatalogService {
                 // May be reset when quickly navigating results.
                 ctx.result.records[idx] = summary;
             }
+
+            if (ctx.highlightData[summary.id]) {
+                summary.displayHighlights = ctx.highlightData[summary.id];
+            }
+        })).toPromise();
+    }
+
+    fetchFieldHighlights(ctx: CatalogSearchContext): Promise<any> {
+
+        let hlMap;
+
+        // Extract the highlight map.  Not all searches have them.
+        if ((hlMap = ctx.result)            &&
+            (hlMap = hlMap.global_summary)  &&
+            (hlMap = hlMap.query_struct)    &&
+            (hlMap = hlMap.additional_data) &&
+            (hlMap = hlMap.highlight_map)   &&
+            (Object.keys(hlMap).length > 0)) {
+        } else { return Promise.resolve(); }
+
+        let ids;
+        if (ctx.getHighlightsFor) {
+            ids = [ctx.getHighlightsFor];
+        } else {
+            // ctx.currentResultIds() returns bib IDs or metabib IDs
+            // depending on the search type.  If we have metabib IDs, map
+            // them to bib IDs for highlighting.
+            ids = ctx.currentResultIds();
+            if (ctx.termSearch.groupByMetarecord) {
+                ids = ids.map(mrId =>
+                    ctx.result.records.filter(r => mrId === r.metabibId)[0].id
+                );
+            }
+        }
+
+        return this.net.requestWithParamList( // API is list-based
+            'open-ils.search',
+            'open-ils.search.fetch.metabib.display_field.highlight',
+            [hlMap].concat(ids)
+        ).pipe(map(fields => {
+
+            if (fields.length === 0) { return; }
+
+            // Each 'fields' collection is an array of display field
+            // values whose text is augmented with highlighting markup.
+            const highlights = ctx.highlightData[fields[0].source] = {};
+
+            fields.forEach(field => {
+                const dfMap = this.cmfMap[field.field].display_field_map();
+                if (!dfMap) { return; } // pretty sure this can't happen.
+
+                if (dfMap.multi() === 't') {
+                    if (!highlights[dfMap.name()]) {
+                        highlights[dfMap.name()] = [];
+                    }
+                    (highlights[dfMap.name()] as string[]).push(field.highlight);
+                } else {
+                    highlights[dfMap.name()] = field.highlight;
+                }
+            });
+
         })).toPromise();
     }
 
@@ -312,14 +371,15 @@ export class CatalogService {
     }
 
     fetchCmfs(): Promise<void> {
-        // At the moment, we only need facet CMFs.
         if (Object.keys(this.cmfMap).length) {
             return Promise.resolve();
         }
 
         return new Promise((resolve, reject) => {
             this.pcrud.search('cmf',
-                {facet_field : 't'}, {}, {atomic: true, anonymous: true}
+                {'-or': [{facet_field : 't'}, {display_field: 't'}]},
+                {flesh: 1, flesh_fields: {cmf: ['display_field_map']}},
+                {atomic: true, anonymous: true}
             ).subscribe(
                 cmfs => {
                     cmfs.forEach(c => this.cmfMap[c.id()] = c);
@@ -371,7 +431,7 @@ export class CatalogService {
         return this.net.request(
             'open-ils.supercat',
             'open-ils.supercat.call_number.browse',
-            cbs.value, ctx.searchOrg.shortname(), ctx.pager.limit, cbs.offset
+            cbs.value, ctx.searchOrg.shortname(), cbs.limit, cbs.offset
         ).pipe(tap(result => ctx.searchState = CatalogSearchState.COMPLETE));
     }
 }
index 041d710..7010d9e 100644 (file)
@@ -158,9 +158,14 @@ export class CatalogCnBrowseContext {
     // e.g. -2 means 2 pages back (alphabetically) from the original search.
     offset: number;
 
+    // Maintain a separate page size limit since it will generally
+    // differ from other search page sizes.
+    limit: number;
+
     reset() {
         this.value = '';
         this.offset = 0;
+        this.limit = 5; // UI will modify
     }
 
     isSearchable() {
@@ -171,6 +176,7 @@ export class CatalogCnBrowseContext {
         const ctx = new CatalogCnBrowseContext();
         ctx.value = this.value;
         ctx.offset = this.offset;
+        ctx.limit = this.limit;
         return ctx;
     }
 
@@ -347,6 +353,12 @@ export class CatalogSearchContext {
     // List of IDs in page/offset context.
     resultIds: number[];
 
+    // If a bib ID is provided, instruct the search code to
+    // only fetch field highlight data for a single record instead
+    // of all search results.
+    getHighlightsFor: number;
+    highlightData: {[id: number]: {[field: string]: string | string[]}} = {};
+
     // Utility stuff
     pager: Pager;
     org: OrgService;
@@ -403,6 +415,7 @@ export class CatalogSearchContext {
         this.showBasket = false;
         this.result = new CatalogSearchResults();
         this.resultIds = [];
+        this.highlightData = {};
         this.searchState = CatalogSearchState.PENDING;
         this.termSearch.reset();
         this.marcSearch.reset();
index 3d98604..41d2b30 100644 (file)
@@ -52,6 +52,8 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit {
 
     @Input() allowFreeText = false;
 
+    @Input() inputSize: number = null;
+
     // Add a 'required' attribute to the input
     isRequired: boolean;
     @Input() set required(r: boolean) {
@@ -73,16 +75,19 @@ export class ComboboxComponent implements ControlValueAccessor, OnInit {
     // Allow the selected entry ID to be passed via the template
     // This does NOT not emit onChange events.
     @Input() set selectedId(id: any) {
-        if (id) {
-            if (this.entrylist.length) {
-                this.selected = this.entrylist.filter(e => e.id === id)[0];
-            }
+        if (id === undefined) { return; }
 
-            if (!this.selected) {
-                // It's possible the selected ID lives in a set of entries
-                // that are yet to be provided.
-                this.startId = id;
-            }
+        // clear on explicit null
+        if (id === null) { this.selected = null; }
+
+        if (this.entrylist.length) {
+            this.selected = this.entrylist.filter(e => e.id === id)[0];
+        }
+
+        if (!this.selected) {
+            // It's possible the selected ID lives in a set of entries
+            // that are yet to be provided.
+            this.startId = id;
         }
     }
 
index 01b16bd..e1f85cd 100644 (file)
@@ -14,6 +14,7 @@ import {DateSelectComponent} from '@eg/share/date-select/date-select.component';
 import {OrgSelectComponent} from '@eg/share/org-select/org-select.component';
 import {DateRangeSelectComponent} from '@eg/share/daterange-select/daterange-select.component';
 import {DateTimeSelectComponent} from '@eg/share/datetime-select/datetime-select.component';
+import {ContextMenuModule} from '@eg/share/context-menu/context-menu.module';
 
 
 @NgModule({
@@ -23,14 +24,15 @@ import {DateTimeSelectComponent} from '@eg/share/datetime-select/datetime-select
     DateSelectComponent,
     OrgSelectComponent,
     DateRangeSelectComponent,
-    DateTimeSelectComponent,
+    DateTimeSelectComponent
   ],
   imports: [
     CommonModule,
     FormsModule,
     ReactiveFormsModule,
     NgbModule,
-    EgCoreModule
+    EgCoreModule,
+    ContextMenuModule
   ],
   exports: [
     CommonModule,
@@ -43,7 +45,8 @@ import {DateTimeSelectComponent} from '@eg/share/datetime-select/datetime-select
     OrgSelectComponent,
     DateRangeSelectComponent,
     DateTimeSelectComponent,
-  ],
+    ContextMenuModule
+  ]
 })
 
 export class CommonWidgetsModule { }
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.css b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.css
new file mode 100644 (file)
index 0000000..3323d2a
--- /dev/null
@@ -0,0 +1,27 @@
+
+.eg-context-menu {
+  /* These fonts were applied specifically for the MARC editor
+   * context menus.  Might want to make these optional. */
+  font-family: 'Lucida Console', Monaco, monospace;
+
+  /* put a hard limit on the popover width */
+  max-width: 550px;
+}
+
+.eg-context-menu .popover-body {
+  max-height: 400px;
+
+  /* Text exceeding the max-height / max-width will results in scrolls.
+   * In most cases, this should not happen. */
+  overflow-y: auto;
+  overflow-x: auto;
+}
+
+.eg-context-menu .popover-body .menu-entry {
+  /* force the menu to expand horizontally to display the text */
+  white-space: nowrap;
+}
+
+.eg-context-menu .popover-body .menu-entry:hover {
+  background-color: #f8f9fa; 
+}
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.html
new file mode 100644 (file)
index 0000000..b25f2cf
--- /dev/null
@@ -0,0 +1,15 @@
+
+<ng-template #menuTemplate>
+
+  <div *ngFor="let entry of menuEntries" class="menu-entry {{entryClasses}}">
+
+    <div *ngIf="entry.divider" class="dropdown-divider"></div>
+
+    <ng-container *ngIf="!entry.divider">
+      <button (click)="entryClicked(entry)" class="btn p-0 m-0">
+        {{entry.label}}
+      </button>
+    </ng-container>
+
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu-container.component.ts
new file mode 100644 (file)
index 0000000..fc218c0
--- /dev/null
@@ -0,0 +1,36 @@
+import {Component, Input, Output, EventEmitter, OnInit, ViewChild,
+    AfterViewInit, TemplateRef, ViewEncapsulation} from '@angular/core';
+import {ContextMenuService, ContextMenu, ContextMenuEntry} from './context-menu.service';
+
+@Component({
+  selector: 'eg-context-menu-container',
+  templateUrl: './context-menu-container.component.html',
+  styleUrls: ['context-menu-container.component.css'],
+  /* Our CSS affects the style of the popover, which may
+   * be beyond our reach for standard view encapsulation */
+  encapsulation: ViewEncapsulation.None
+})
+
+export class ContextMenuContainerComponent implements OnInit, AfterViewInit {
+
+    menuEntries: ContextMenuEntry[] = [];
+    @ViewChild('menuTemplate', {static: false}) menuTemplate: TemplateRef<any>;
+
+    constructor(private menuService: ContextMenuService) {}
+
+    ngOnInit() {
+        this.menuService.showMenuRequest.subscribe(
+            (menu: ContextMenu) => {
+            this.menuEntries = menu.entries;
+        });
+    }
+
+    ngAfterViewInit() {
+        this.menuService.menuTemplate = this.menuTemplate;
+    }
+
+    entryClicked(entry: ContextMenuEntry) {
+        this.menuService.menuItemSelected.emit(entry);
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.directive.ts
new file mode 100644 (file)
index 0000000..2b22c4c
--- /dev/null
@@ -0,0 +1,107 @@
+import {Input, Output, EventEmitter, Directive} from '@angular/core';
+import {NgbPopover} from '@ng-bootstrap/ng-bootstrap';
+import {ContextMenuService, ContextMenu, ContextMenuEntry} from './context-menu.service';
+
+
+/* Import all of this stuff so we can pass it to our parent
+ * class via its constructor */
+import {
+    Inject, Injector, Renderer2, ElementRef, TemplateRef, ViewContainerRef,
+    ComponentFactoryResolver, NgZone, ChangeDetectorRef, ApplicationRef
+} from '@angular/core';
+import {DOCUMENT} from '@angular/common';
+import {NgbPopoverConfig} from '@ng-bootstrap/ng-bootstrap';
+/* --- */
+
+@Directive({
+  selector: '[egContextMenu]',
+  exportAs: 'egContextMenu'
+})
+export class ContextMenuDirective extends NgbPopover {
+
+    // Only one active menu is allowed at a time.
+    static activeDirective: ContextMenuDirective;
+    static menuId = 0;
+
+    triggers = 'contextmenu';
+    popoverClass = 'eg-context-menu';
+
+    menuEntries: ContextMenuEntry[] = [];
+    menu: ContextMenu;
+
+    @Input() set egContextMenu(menuEntries: ContextMenuEntry[]) {
+        this.menuEntries = menuEntries;
+    }
+
+    @Output() menuItemSelected: EventEmitter<ContextMenuEntry>;
+
+    constructor(
+        p1: ElementRef<HTMLElement>, p2: Renderer2, p3: Injector,
+        p4: ComponentFactoryResolver, p5: ViewContainerRef, p6: NgbPopoverConfig,
+        p7: NgZone, @Inject(DOCUMENT) p8: any, p9: ChangeDetectorRef,
+        p10: ApplicationRef, private menuService: ContextMenuService) {
+
+        // relay injected services to parent
+        super(p1, p2, p3, p4, p5, p6, p7, p8, p9, p10);
+
+        this.menuItemSelected = new EventEmitter<ContextMenuEntry>();
+
+        this.menuService.menuItemSelected.subscribe(
+            (entry: ContextMenuEntry) => {
+
+            // Only broadcast entry selection to my listeners if I'm
+            // hosting the menu where the selection occurred.
+
+            if (this.activeMenuIsMe()) {
+                this.menuItemSelected.emit(entry);
+
+                // Item selection via keyboard fails to close the menu.
+                // Force it closed.
+                this.cleanup();
+            }
+        });
+    }
+
+    activeMenuIsMe(): boolean {
+        return (
+            this.menu &&
+            this.menuService.activeMenu &&
+            this.menu.id === this.menuService.activeMenu.id
+        );
+    }
+
+    // Close the active menu
+    cleanup() {
+        if (ContextMenuDirective.activeDirective) {
+            ContextMenuDirective.activeDirective.close();
+            ContextMenuDirective.activeDirective = null;
+            this.menuService.activeMenu = null;
+        }
+    }
+
+    open() {
+
+        // In certain scenarios (e.g. right-clicking on another context
+        // menu) an open popover will stay open.  Force it closed here.
+        this.cleanup();
+
+        if (!this.menuEntries ||
+             this.menuEntries.length === 0) {
+             return;
+        }
+
+        this.menu = new ContextMenu();
+        this.menu.id = ContextMenuDirective.menuId++;
+        this.menu.entries = this.menuEntries;
+
+        this.menuService.activeMenu = this.menu;
+        this.menuService.showMenuRequest.emit(this.menu);
+        this.ngbPopover = this.menuService.menuTemplate;
+
+        ContextMenuDirective.activeDirective = this;
+
+        super.open();
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.module.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.module.ts
new file mode 100644 (file)
index 0000000..fb25e61
--- /dev/null
@@ -0,0 +1,24 @@
+import {NgModule} from '@angular/core';
+import {CommonModule} from '@angular/common';
+import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
+import {ContextMenuService} from './context-menu.service';
+import {ContextMenuDirective} from './context-menu.directive';
+import {ContextMenuContainerComponent} from './context-menu-container.component';
+
+@NgModule({
+  declarations: [
+    ContextMenuDirective,
+    ContextMenuContainerComponent
+  ],
+  imports: [
+    CommonModule,
+    NgbModule
+  ],
+  exports: [
+    ContextMenuDirective,
+    ContextMenuContainerComponent
+  ]
+})
+
+export class ContextMenuModule { }
+
diff --git a/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts b/Open-ILS/src/eg2/src/app/share/context-menu/context-menu.service.ts
new file mode 100644 (file)
index 0000000..cd94700
--- /dev/null
@@ -0,0 +1,33 @@
+import {Injectable, EventEmitter, TemplateRef} from '@angular/core';
+import {tap} from 'rxjs/operators';
+
+/* Relay requests to/from the context menu directive and its
+ * template container component */
+
+export interface ContextMenuEntry {
+    value?: string;
+    label?: string;
+    divider?: boolean;
+}
+
+export class ContextMenu {
+    id: number;
+    entries: ContextMenuEntry[];
+}
+
+@Injectable({providedIn: 'root'})
+export class ContextMenuService {
+
+    showMenuRequest: EventEmitter<ContextMenu>;
+    menuItemSelected: EventEmitter<ContextMenuEntry>;
+
+    menuTemplate: TemplateRef<any>;
+    activeMenu: ContextMenu;
+
+    constructor() {
+        this.showMenuRequest = new EventEmitter<ContextMenu>();
+        this.menuItemSelected = new EventEmitter<ContextMenuEntry>();
+    }
+}
+
+
index 58c400f..e809b7d 100644 (file)
@@ -100,6 +100,9 @@ export class FmRecordEditorComponent
     // behaviour for each field (by field name).
     @Input() fieldOptions: {[fieldName: string]: FmFieldOptions} = {};
 
+    // This is used to set default values when making a new record
+    @Input() defaultNewRecord: IdlObject;
+
     // list of fields that should not be displayed
     @Input() hiddenFieldsList: string[] = [];
     @Input() hiddenFields: string; // comma-separated string version
@@ -356,8 +359,16 @@ export class FmRecordEditorComponent
         //
         // Create a new record from the stub record provided by the
         // caller or a new from-scratch record
-        // Set this._record (not this.record) to avoid loop in initRecord()
-        this._record = this.record || this.idl.create(this.idlClass);
+        if (!this.record) {
+            // NOTE: Set this._record (not this.record) to avoid
+            // loop in initRecord()
+            if (this.defaultNewRecord) {
+                // Clone to avoid polluting the stub record
+                this._record = this.idl.clone(this.defaultNewRecord);
+            } else {
+                this._record = this.idl.create(this.idlClass);
+            }
+        }
         this._recordId = null; // avoid future confusion
 
         return this.getFieldList();
index e4f6715..e885fb7 100644 (file)
@@ -230,9 +230,11 @@ export class GridColumnSet {
             if (idlInfo) {
                 col.idlFieldDef = idlInfo.idlField;
                 col.idlClass = idlInfo.idlClass.name;
+                if (!col.datatype) {
+                    col.datatype = col.idlFieldDef.datatype;
+                }
                 if (!col.label) {
                     col.label = col.idlFieldDef.label || col.idlFieldDef.name;
-                    col.datatype = col.idlFieldDef.datatype;
                 }
             }
         }
diff --git a/Open-ILS/src/eg2/src/app/share/util/can-deactivate.guard.ts b/Open-ILS/src/eg2/src/app/share/util/can-deactivate.guard.ts
new file mode 100644 (file)
index 0000000..c0ddeff
--- /dev/null
@@ -0,0 +1,33 @@
+import {Injectable} from '@angular/core';
+import {CanDeactivate} from '@angular/router';
+import {Observable} from 'rxjs';
+
+/**
+ * https://angular.io/guide/router#candeactivate-handling-unsaved-changes
+ *
+ * routing:
+ * {
+ *   path: 'record/:id/:tab',
+ *   component: MyComponent,
+ *   canDeactivate: [CanDeactivateGuard]
+ * }
+ *
+ * export class MyComponent {
+ *   canDeactivate() ... {
+ *      ...
+ *   }
+ * }
+ */
+
+export interface CanComponentDeactivate {
+    canDeactivate: () => Observable<boolean> | Promise<boolean> | boolean;
+}
+
+@Injectable({providedIn: 'root'})
+export class CanDeactivateGuard
+    implements CanDeactivate<CanComponentDeactivate> {
+
+    canDeactivate(component: CanComponentDeactivate) {
+        return component.canDeactivate ? component.canDeactivate() : true;
+    }
+}
index b73a969..6740171 100644 (file)
@@ -66,7 +66,7 @@
 
 <ng-template #bucketTemplate
     let-field="field" let-record="record">
-  <span *ngIf="record[field.name]()" i18n>
+  <span *ngIf="record[field.name]()">
     <a href="/eg/staff/cat/bucket/record/view/{{record[field.name]()}}" target="_blank" i18n>Link to bucket</a>
     <span *ngIf="record['type']() !== 1" i18n style="font-style: italic"> (Note: changes to bucket contents may be overwritten by the next carousel update.)</span>
   </span>
index e051d37..223d181 100644 (file)
     <eg-link-table-link i18n-label label="Statistical Popularity Badges" 
       routerLink="/staff/admin/local/rating/badge"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Surveys" 
-      url="/eg/staff/admin/local/action/survey"></eg-link-table-link>
+      routerLink="/staff/admin/local/action/survey"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Transit List" 
       url="/eg/staff/circ/transits/list"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Work Log" 
       url="/eg/staff/admin/workstation/log"></eg-link-table-link>
-
   </eg-link-table>
 </div>
index 39c6be7..15a9153 100644 (file)
@@ -23,6 +23,9 @@ const routes: Routes = [{
     path: 'config/standing_penalty',
     component: StandingPenaltyComponent
 }, {
+    path: 'action/survey',
+    loadChildren: '@eg/staff/admin/local/survey/survey.module#SurveyModule'
+}, {
     path: ':schema/:table',
     component: BasicAdminPageComponent
 }];
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/survey/survey-edit.component.html b/Open-ILS/src/eg2/src/app/staff/admin/local/survey/survey-edit.component.html
new file mode 100644 (file)
index 0000000..86f2f20
--- /dev/null
@@ -0,0 +1,124 @@
+<eg-staff-banner bannerText="Survey ID # {{surveyId}}" i18n-bannerText
+                class="mb-3"></eg-staff-banner>
+<ngb-tabset #surveyTabs [activeId]="surveyTab" (tabChange)="onTabChange($event)" class="mb-3">
+    <ngb-tab title="Edit Survey" i18n-title id="edit">
+        <ng-template ngbTabContent>
+            <div class="col-lg-6 offset-lg-3 mt-3">
+                <div style="text-align: center;">
+                    <button class="p-2 mb-3 btn btn-danger btn-lg" 
+                    (click)="endSurvey()" i18n>
+                        End Survey Now
+                    </button>
+                </div>
+                <eg-fm-record-editor displayMode="inline" 
+                    hiddenFieldsList="id"
+                    datetimeFieldsList="start_date,end_date"
+                    idlClass="asv" 
+                    mode="update" 
+                    [record]="surveyObj">
+                </eg-fm-record-editor>
+            </div>
+        </ng-template>
+    </ngb-tab>
+    <ngb-tab title="Questions and Answers" i18n-title id="qanda">
+        <ng-template ngbTabContent>
+            <div class="col-lg-8 offset-lg-2 mt-3">
+                <eg-staff-banner bannerText="Questions & Answers" i18n-bannerText>
+                    </eg-staff-banner>
+                <div *ngFor="let question of localArray; let questionIndex = index;">
+                    <div class="mb-3 mt-3 p-2 bg-light input-group">
+                        <label class="input-group-text">
+                            <b>Question</b>
+                        </label>
+                        <input type="text" [(ngModel)]="question.words" class="form-control"
+                            name="question-{{questionIndex}}">
+                        <span class="input-group-append">
+                            <button class="ml-2 btn btn-info" 
+                                (click)="updateQuestion(question)" i18n>
+                                Save
+                            </button>
+                            <button class="ml-1 btn btn-danger"
+                                (click)="deleteQuestion(question)" i18n>
+                                Delete Question & Answers
+                            </button>
+                        </span>
+                    </div>
+                    <div *ngFor="let answer of question.answers; let answerIndex = index;" 
+                        class="mb-2 input-group">
+                        <input class="form-control" type="text" 
+                            [(ngModel)]="answer.words"
+                            name="answer-{{questionIndex}}-{{answerIndex}}">
+                        <span class="input-group-append">
+                            <button class="ml-2 btn btn-info" 
+                                (click)="updateAnswer(answer, question, questionIndex, answerIndex)"
+                                i18n>
+                                Save
+                            </button>
+                            <button class="ml-1 btn btn-danger" (click)="deleteAnswer(answer)"
+                                i18n>
+                                Delete
+                            </button>
+                        </span>
+                    </div>
+                    <div class="mb-2 input-group">
+                        <input class="form-control" type="text" 
+                            [(ngModel)]="newAnswerArray[questionIndex].inputText"
+                                value="">
+                        <span class="input-group-append">
+                            <button class="ml-2 btn btn-info" 
+                                (click)="createAnswer(newAnswerArray[questionIndex].inputText, question)"
+                                i18n>
+                                Add Answer
+                            </button>
+                        </span>
+                    </div>
+                </div>
+                <div class="mb-3 mt-3 p-2 bg-light input-group">
+                    <label class="input-group-text">
+                        <b>New Question</b>
+                    </label>
+                    <input #newQuestionInput 
+                        class="form-control" 
+                        type="text" 
+                        [(ngModel)]="newQuestionText"
+                        name="question-new" value="">
+                    <span class="input-group-append">
+                        <button class="ml-2 btn btn-info"
+                            (click)="createQuestion(newQuestionText)" i18n>
+                            Save Question & Add Answer
+                        </button>
+                    </span>
+                </div>
+            </div>
+        </ng-template>
+    </ngb-tab>
+</ngb-tabset>
+
+<eg-string #createAnswerString i18n-text text="New Answer Added"></eg-string>
+<eg-string #createAnswerErrString i18n-text text="Failed to Create New Answer">
+    </eg-string>
+<eg-string #createQuestionString i18n-text text="New Question Added"></eg-string>
+<eg-string #createQuestionErrString i18n-text text="Failed to Create New Question">
+    </eg-string>
+<eg-string #delAnswerSuccessStr i18n-text text="Survey Answer deleted">
+    </eg-string>
+<eg-string #delAnswerFailStr i18n-text text="Survey Answer deletion failed">
+    </eg-string>
+<eg-string #delQuestionSuccessStr i18n-text text="Survey Question deleted">
+    </eg-string>
+<eg-string #delQuestionFailStr i18n-text text="Survey Question deletion failed">
+    </eg-string>
+<eg-string #updateAnswerSuccessStr i18n-text text="Survey Answer updated">
+    </eg-string>
+<eg-string #updateAnswerFailStr i18n-text text="Survey Answer update failed">
+    </eg-string>
+<eg-string #updateQuestionSuccessStr i18n-text text="Survey Question updated">
+    </eg-string>
+<eg-string #updateQuestionFailStr i18n-text text="Survey Question update failed">
+    </eg-string>
+<eg-string #endSurveyFailedString i18n-text 
+    text="Ending Survey failed or was not allowed"></eg-string>
+<eg-string #endSurveySuccessString i18n-text text="Survey ended"></eg-string>
+<eg-string #questionAlreadyStartedErrString i18n-text 
+    text="The survey Start Date must be set for the future to add new questions or modify existing questions.">
+    </eg-string>
\ No newline at end of file
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/survey/survey-edit.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/local/survey/survey-edit.component.ts
new file mode 100644 (file)
index 0000000..73b3a7b
--- /dev/null
@@ -0,0 +1,307 @@
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {ActivatedRoute} from '@angular/router';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {IdlObject, IdlService } from '@eg/core/idl.service';
+import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
+
+@Component({
+    templateUrl: './survey-edit.component.html'
+})
+
+export class SurveyEditComponent implements OnInit {
+    surveyId: number;
+    surveyObj: IdlObject;
+    localArray: any;
+    newAnswerArray: object[];
+    newQuestionText: string;
+    surveyTab: string;
+
+    @ViewChild('editDialog', { static: true }) editDialog: FmRecordEditorComponent;
+
+    @ViewChild('createAnswerString', { static: true })
+        createAnswerString: StringComponent;
+    @ViewChild('createAnswerErrString', { static: true })
+        createAnswerErrString: StringComponent;
+    @ViewChild('createQuestionString', { static: true })
+        createQuestionString: StringComponent;
+    @ViewChild('createQuestionErrString', { static: true })
+        createQuestionErrString: StringComponent;
+
+    @ViewChild('updateQuestionSuccessStr', { static: true })
+        updateQuestionSuccessStr: StringComponent;
+    @ViewChild('updateQuestionFailStr', { static: true })
+        updateQuestionFailStr: StringComponent;
+    @ViewChild('updateAnswerSuccessStr', { static: true })
+        updateAnswerSuccessStr: StringComponent;
+    @ViewChild('updateAnswerFailStr', { static: true })
+        updateAnswerFailStr: StringComponent;
+
+    @ViewChild('delAnswerSuccessStr', { static: true })
+        delAnswerSuccessStr: StringComponent;
+    @ViewChild('delAnswerFailStr', { static: true })
+        delAnswerFailStr: StringComponent;
+    @ViewChild('delQuestionSuccessStr', { static: true })
+        delQuestionSuccessStr: StringComponent;
+    @ViewChild('delQuestionFailStr', { static: true })
+        delQuestionFailStr: StringComponent;
+
+    @ViewChild('endSurveyFailedString', { static: true })
+        endSurveyFailedString: StringComponent;
+    @ViewChild('endSurveySuccessString', { static: true })
+        endSurveySuccessString: StringComponent;
+    @ViewChild('questionAlreadyStartedErrString', { static: true })
+        questionAlreadyStartedErrString: StringComponent;
+
+    constructor(
+        private auth: AuthService,
+        private net: NetService,
+        private route: ActivatedRoute,
+        private toast: ToastService,
+        private idl: IdlService,
+    ) {
+    }
+
+    ngOnInit() {
+        this.surveyId = parseInt(this.route.snapshot.paramMap.get('id'), 10);
+        this.updateData();
+    }
+
+    updateData() {
+        this.newQuestionText = '';
+        this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.survey.fleshed.retrieve',
+            this.surveyId
+        ).subscribe(res => {
+            this.surveyObj = res;
+            this.buildLocalArray(res);
+            return res;
+        });
+    }
+
+    onTabChange(event: NgbTabChangeEvent) {
+        this.surveyTab = event.nextId;
+    }
+
+    buildLocalArray(res) {
+        this.localArray = [];
+        this.newAnswerArray = [];
+        const allQuestions = res.questions();
+        allQuestions.forEach((question, index) => {
+            this.newAnswerArray.push({inputText: ''});
+            question.words = question.question();
+            question.answers = question.answers();
+            this.localArray.push(question);
+            question.answers.forEach(answer => {
+                answer.words = answer.answer();
+            });
+            this.sortAnswers(index);
+        });
+        this.sortQuestions();
+    }
+
+    sortQuestions() {
+        this.localArray.sort(function(a, b) {
+            const q1 = a.question().toUpperCase();
+            const q2 = b.question().toUpperCase();
+            return (q1 < q2) ? -1 : (q1 > q2) ? 1 : 0;
+        });
+    }
+
+    sortAnswers(questionIndex) {
+        this.localArray[questionIndex].answers.sort(function(a, b) {
+            const a1 = a.answer().toUpperCase();
+            const a2 = b.answer().toUpperCase();
+            return (a1 < a2) ? -1 : (a1 > a2) ? 1 : 0;
+        });
+    }
+
+    updateQuestion(questionToChange) {
+        if (this.surveyHasBegun()) {
+            return;
+        }
+        questionToChange.question(questionToChange.words);
+        questionToChange.ischanged(true);
+        this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.survey.update',
+            this.auth.token(), this.surveyObj
+        ).subscribe(res => {
+            if (res.debug) {
+                this.updateQuestionFailStr.current().then(msg => this.toast.warning(msg));
+                return res;
+            } else {
+                this.surveyObj = res;
+                this.buildLocalArray(this.surveyObj);
+                this.updateQuestionSuccessStr.current().then(msg => this.toast.success(msg));
+                return res;
+            }
+        });
+    }
+
+    deleteQuestion(questionToDelete) {
+        if (this.surveyHasBegun()) {
+            return;
+        }
+        questionToDelete.isdeleted(true);
+        this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.survey.update',
+            this.auth.token(), this.surveyObj
+        ).subscribe(res => {
+            if (res.debug) {
+                this.delQuestionFailStr.current().then(msg => this.toast.warning(msg));
+                return res;
+            } else {
+                this.surveyObj = res;
+                this.buildLocalArray(this.surveyObj);
+                this.delQuestionSuccessStr.current().then(msg => this.toast.success(msg));
+                return res;
+            }
+
+        });
+    }
+
+    createQuestion(newQuestionText) {
+        if (this.surveyHasBegun()) {
+            return;
+        }
+        const newQuestion = this.idl.create('asvq');
+        newQuestion.question(newQuestionText);
+        newQuestion.isnew(true);
+        let questionObjects = [];
+        questionObjects = this.surveyObj.questions();
+        questionObjects.push(newQuestion);
+        this.surveyObj.questions(questionObjects);
+        this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.survey.update',
+            this.auth.token(), this.surveyObj
+        ).subscribe(res => {
+            if (res.debug) {
+                this.newQuestionText = '';
+                this.createQuestionErrString.current().then(msg => this.toast.warning(msg));
+                return res;
+            } else {
+                this.surveyObj = res;
+                this.buildLocalArray(this.surveyObj);
+                this.newQuestionText = '';
+                this.createQuestionString.current().then(msg => this.toast.success(msg));
+                return res;
+            }
+
+        });
+    }
+
+    deleteAnswer(answerObj) {
+        if (this.surveyHasBegun()) {
+            return;
+        }
+        answerObj.isdeleted(true);
+        this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.survey.update',
+            this.auth.token(), this.surveyObj
+        ).subscribe(res => {
+            if (res.debug) {
+                this.delAnswerFailStr.current().then(msg => this.toast.warning(msg));
+                return res;
+            } else {
+                this.surveyObj = res;
+                this.buildLocalArray(this.surveyObj);
+                this.delAnswerSuccessStr.current().then(msg => this.toast.success(msg));
+                return res;
+            }
+        });
+    }
+
+    updateAnswer(answerObj) {
+        if (this.surveyHasBegun()) {
+            return;
+        }
+        answerObj.answer(answerObj.words);
+        answerObj.ischanged(true);
+        this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.survey.update',
+            this.auth.token(), this.surveyObj
+        ).subscribe(res => {
+            if (res.debug) {
+                this.updateAnswerFailStr.current().then(msg => this.toast.warning(msg));
+                return res;
+            } else {
+                this.surveyObj = res;
+                this.buildLocalArray(this.surveyObj);
+                this.updateAnswerSuccessStr.current().then(msg => this.toast.success(msg));
+                return res;
+            }
+        });
+    }
+
+    createAnswer(newAnswerText, questionObj) {
+        // Create answer *is* allowed if survey has already begun
+        const questionId = questionObj.id();
+        const newAnswer = this.idl.create('asva');
+        newAnswer.answer(newAnswerText);
+        newAnswer.question(questionId);
+        newAnswer.isnew(true);
+        questionObj.answers.push(newAnswer);
+        this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.survey.update',
+            this.auth.token(), this.surveyObj
+        ).subscribe(res => {
+            if (res.debug) {
+                this.createAnswerErrString.current().then(msg => this.toast.warning(msg));
+                return res;
+            } else {
+                this.surveyObj = res;
+                this.buildLocalArray(this.surveyObj);
+                this.createAnswerString.current().then(msg => this.toast.success(msg));
+                return res;
+            }
+        });
+    }
+
+    endSurvey() {
+        const today = new Date().toISOString();
+        this.surveyObj.end_date(today);
+        this.surveyObj.ischanged(true);
+        // to get fm-editor to display changed date we need to set
+        // this.surveyObj to null temporarily
+        const surveyClone = this.idl.clone(this.surveyObj);
+        this.surveyObj = null;
+        this.net.request(
+            'open-ils.circ',
+            'open-ils.circ.survey.update',
+            this.auth.token(), surveyClone
+        ).subscribe(res => {
+            if (res.debug) {
+                this.endSurveyFailedString.current().then(msg => this.toast.warning(msg));
+                return res;
+            } else {
+                this.surveyObj = res;
+                this.surveyObj.ischanged(false);
+                this.buildLocalArray(this.surveyObj);
+                this.endSurveySuccessString.current().then(msg => this.toast.success(msg));
+                return res;
+            }
+        });
+    }
+
+    surveyHasBegun() {
+        const surveyStartDate = new Date(this.surveyObj.start_date());
+        const now = new Date();
+        if (surveyStartDate <= now) {
+            this.questionAlreadyStartedErrString.current().then(msg =>
+                this.toast.warning(msg));
+            return true;
+        }
+        return false;
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/survey/survey-routing.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/local/survey/survey-routing.module.ts
new file mode 100644 (file)
index 0000000..cd36869
--- /dev/null
@@ -0,0 +1,22 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {SurveyComponent} from './survey.component';
+import {SurveyEditComponent} from './survey-edit.component';
+
+const routes: Routes = [{
+    path: '',
+    component: SurveyComponent
+}, {
+    path: ':id',
+    component: SurveyEditComponent
+}];
+
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule]
+})
+
+export class SurveyRoutingModule {}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/survey/survey.component.html b/Open-ILS/src/eg2/src/app/staff/admin/local/survey/survey.component.html
new file mode 100644 (file)
index 0000000..394d837
--- /dev/null
@@ -0,0 +1,37 @@
+<eg-staff-banner bannerText="Survey Configuration" i18n-bannerText>
+</eg-staff-banner>
+
+<eg-grid #grid idlClass="asv" [dataSource]="gridDataSource" 
+[sortable]="true">
+    <eg-grid-toolbar-button label="New Survey" i18n-label [action]="createNew">
+    </eg-grid-toolbar-button>
+    <eg-grid-toolbar-action label="Edit Selected" i18n-label [action]="editSelected">
+    </eg-grid-toolbar-action>
+    <eg-grid-toolbar-action label="Delete Selected" i18n-label 
+    (onClick)="deleteSelected($event)"></eg-grid-toolbar-action>
+    <eg-grid-toolbar-action label="End Survey Now" i18n-label 
+    (onClick)="endSurvey($event)"></eg-grid-toolbar-action>
+</eg-grid>
+
+<eg-fm-record-editor 
+    datetimeFieldsList="start_date,end_date"
+    hiddenFieldsList="id"
+    #editDialog 
+    idlClass="asv">
+</eg-fm-record-editor>
+
+<eg-string #createString i18n-text text="New Survey Added"></eg-string>
+<eg-string #createErrString i18n-text text="Failed to Create New Survey">
+    </eg-string>
+<eg-string #endSurveyFailedString i18n-text 
+    text="Ending Survey failed or was not allowed"></eg-string>
+<eg-string #endSurveySuccessString i18n-text text="Survey ended">
+    </eg-string>
+<eg-string #deleteFailedString i18n-text 
+    text="Delete of Survey failed or was not allowed"></eg-string>
+<eg-string #deleteSuccessString i18n-text text="Delete of Survey succeeded">
+    </eg-string>
+<eg-string #successString i18n-text text="Update of Survey succeeded">
+    </eg-string>
+<eg-string #updateFailedString i18n-text text="Update of Survey succeeded">
+    </eg-string>
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/survey/survey.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/local/survey/survey.component.ts
new file mode 100644 (file)
index 0000000..9865a4b
--- /dev/null
@@ -0,0 +1,122 @@
+import {Pager} from '@eg/share/util/pager';
+import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {Router} from '@angular/router';
+import {IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+
+@Component({
+    templateUrl: './survey.component.html'
+})
+
+export class SurveyComponent implements OnInit {
+
+    gridDataSource: GridDataSource;
+
+    @ViewChild('editDialog', { static: true }) editDialog: FmRecordEditorComponent;
+    @ViewChild('grid', { static: true }) grid: GridComponent;
+    @ViewChild('successString', { static: true }) successString: StringComponent;
+    @ViewChild('createString', { static: true }) createString: StringComponent;
+    @ViewChild('createErrString', { static: true }) createErrString: StringComponent;
+    @ViewChild('updateFailedString', { static: true }) updateFailedString: StringComponent;
+    @ViewChild('deleteFailedString', { static: true }) deleteFailedString: StringComponent;
+    @ViewChild('deleteSuccessString', { static: true }) deleteSuccessString: StringComponent;
+    @ViewChild('endSurveyFailedString', { static: true }) endSurveyFailedString: StringComponent;
+    @ViewChild('endSurveySuccessString', { static: true }) endSurveySuccessString: StringComponent;
+
+    @Input() dialogSize: 'sm' | 'lg' = 'lg';
+
+    constructor(
+        private auth: AuthService,
+        private net: NetService,
+        private pcrud: PcrudService,
+        private toast: ToastService,
+        private router: Router
+    ) {
+        this.gridDataSource = new GridDataSource();
+    }
+
+    ngOnInit() {
+        this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
+            return this.pcrud.retrieveAll('asv', {});
+        };
+
+        this.grid.onRowActivate.subscribe(
+            (idlThing: IdlObject) => {
+                const idToEdit = idlThing.id();
+                this.navigateToEditPage(idToEdit);
+            }
+        );
+    }
+
+    showEditDialog(idlThing: IdlObject): Promise<any> {
+        return;
+    }
+
+    editSelected = (surveys: IdlObject[]) => {
+        const idToEdit = surveys[0].id();
+        this.navigateToEditPage(idToEdit);
+    }
+
+    endSurvey = (surveys: IdlObject[]) => {
+        const today = new Date().toISOString();
+        for (let i = 0; i < surveys.length; i++) {
+            surveys[i].end_date(today);
+            this.pcrud.update(surveys[i]).toPromise().then(
+                async (ok) => {
+                    this.toast.success(await this.endSurveySuccessString.current());
+                },
+                async (err) => {
+                    this.toast.warning(await this.endSurveyFailedString.current());
+                }
+            );
+        }
+    }
+
+    deleteSelected = (surveys: IdlObject[]) => {
+        for (let i = 0; i < surveys.length; i++) {
+            const idToDelete = surveys[i].id();
+            this.net.request(
+                'open-ils.circ',
+                'open-ils.circ.survey.delete.cascade.override',
+                this.auth.token(), idToDelete
+            ).subscribe(res => {
+                this.deleteSuccessString.current()
+                    .then(str => this.toast.success(str));
+                this.grid.reload();
+                return res;
+            }, (err) => {
+                this.deleteFailedString.current()
+                    .then(str => this.toast.success(str));
+            });
+        }
+    }
+
+    navigateToEditPage(id: any) {
+        this.router.navigate(['/staff/admin/local/action/survey/' + id]);
+    }
+
+    createNew = () => {
+        this.editDialog.mode = 'create';
+        this.editDialog.datetimeFields = 'start_date,end_date';
+        this.editDialog.open({size: this.dialogSize}).subscribe(
+            ok => {
+                this.createString.current()
+                    .then(str => this.toast.success(str));
+                this.grid.reload();
+            },
+            rejection => {
+                if (!rejection.dismissed) {
+                    this.createErrString.current()
+                        .then(str => this.toast.danger(str));
+                }
+            }
+        );
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/survey/survey.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/local/survey/survey.module.ts
new file mode 100644 (file)
index 0000000..21ba53f
--- /dev/null
@@ -0,0 +1,25 @@
+import {NgModule} from '@angular/core';
+import {AdminCommonModule} from '@eg/staff/admin/common.module';
+import {SurveyComponent} from './survey.component';
+import {FormsModule} from '@angular/forms';
+import {SurveyEditComponent} from './survey-edit.component';
+import {SurveyRoutingModule} from './survey-routing.module';
+
+@NgModule({
+  declarations: [
+    SurveyComponent,
+    SurveyEditComponent
+  ],
+  imports: [
+    AdminCommonModule,
+    SurveyRoutingModule,
+    FormsModule,
+  ],
+  exports: [
+  ],
+  providers: [
+  ]
+})
+
+export class SurveyModule {
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/floating-group/edit-floating-group.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/floating-group/edit-floating-group.component.html
new file mode 100644 (file)
index 0000000..55b02b7
--- /dev/null
@@ -0,0 +1,28 @@
+<eg-title i18n-prefix prefix="Edit Floating Group"></eg-title>
+<eg-staff-banner bannerText="Edit Floating Group" i18n-bannerText>
+</eg-staff-banner>
+
+<div class="row">
+    <div class="col text-right">
+        <a class="btn btn-warning ml-3" routerLink="/staff/admin/server/config/floating_group">
+            <span class="material-icons align-middle">keyboard_return</span>
+            <span class="align-middle" i18n>Return to Floating Groups List</span>
+        </a>
+    </div>
+</div>
+
+
+<div class="col-lg-6 offset-lg-3">
+    <eg-fm-record-editor displayMode="inline"
+        idlClass="cfg" mode="update" recordId="{{this.currentId}}">
+    </eg-fm-record-editor>
+</div>
+
+<eg-staff-banner bannerText="Edit Floating Group Members" i18n-bannerText>
+</eg-staff-banner>
+<eg-admin-page idlClass="cfgm" disableOrgFilter="true" 
+    hideGridFields="id,floating_group"
+    readonlyFields="floating_group"
+    [dataSource]="dataSource" 
+    [defaultNewRecord]="defaultNewRecord">
+</eg-admin-page>
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/floating-group/edit-floating-group.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/floating-group/edit-floating-group.component.ts
new file mode 100644 (file)
index 0000000..e6785e8
--- /dev/null
@@ -0,0 +1,51 @@
+import {Component, Input, OnInit} from '@angular/core';
+import {Router, ActivatedRoute} from '@angular/router';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {Pager} from '@eg/share/util/pager';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {IdlObject, IdlService } from '@eg/core/idl.service';
+
+@Component({
+    templateUrl: './edit-floating-group.component.html'
+})
+
+export class EditFloatingGroupComponent implements OnInit {
+
+    dataSource: GridDataSource;
+
+    // defaultNewRecord is used when creating a new entry to give a default floating_group
+    defaultNewRecord: IdlObject;
+
+    // This is the ID of the floating group being edited currently
+    currentId: number;
+
+    constructor(
+        private route: ActivatedRoute,
+        private pcrud: PcrudService,
+        private idl: IdlService,
+    ) {
+    }
+
+    ngOnInit() {
+        this.currentId = parseInt(this.route.snapshot.paramMap.get('id'), 10);
+        this.defaultNewRecord = this.idl.create('cfgm');
+        this.defaultNewRecord.floating_group(this.currentId);
+        this.dataSource = new GridDataSource();
+
+        this.dataSource.getRows = (pager: Pager, sort: any[]) => {
+            const orderBy: any = {};
+            if (sort.length) {
+                orderBy.cfgm = sort[0].name + ' ' + sort[0].dir;
+            }
+
+            const searchOps = {
+                offset: pager.offset,
+                limit: pager.limit,
+                order_by: orderBy
+            };
+
+            return this.pcrud.search('cfgm',
+                {floating_group: this.currentId}, searchOps);
+        };
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/floating-group/floating-group-routing.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/floating-group/floating-group-routing.module.ts
new file mode 100644 (file)
index 0000000..4e368de
--- /dev/null
@@ -0,0 +1,19 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {EditFloatingGroupComponent} from './edit-floating-group.component';
+import {FloatingGroupComponent} from './floating-group.component';
+
+const routes: Routes = [{
+    path: ':id',
+    component: EditFloatingGroupComponent
+  }, {
+    path: '',
+    component: FloatingGroupComponent
+}];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule]
+})
+
+export class FloatingGroupRoutingModule {}
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/floating-group/floating-group.component.html b/Open-ILS/src/eg2/src/app/staff/admin/server/floating-group/floating-group.component.html
new file mode 100644 (file)
index 0000000..66084e5
--- /dev/null
@@ -0,0 +1,22 @@
+<eg-staff-banner bannerText="Floating Group Administration" i18n-bannerText>
+</eg-staff-banner>
+
+<eg-string #createString i18n-text text="New Floating Group Added"></eg-string>
+<eg-string #createErrString i18n-text text="Failed to Create New Floating Group">
+</eg-string>
+<eg-string #deleteFailedString i18n-text 
+  text="Delete of Floating Group failed or was not allowed"></eg-string>
+<eg-string #deleteSuccessString i18n-text 
+  text="Delete of Floating Group succeeded"></eg-string>
+
+<eg-grid #grid idlClass="cfg" [dataSource]="gridDataSource" [sortable]="true">
+  <eg-grid-toolbar-button
+    label="New Floating Group" i18n-label (onClick)="createNew()">
+  </eg-grid-toolbar-button>
+  <eg-grid-toolbar-action label="Edit Selected" i18n-label [action]="editSelected">
+  </eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Delete Selected" i18n-label
+    [action]="deleteSelected"></eg-grid-toolbar-action>
+</eg-grid>
+<eg-fm-record-editor #editDialog idlClass="cfg">
+</eg-fm-record-editor>
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/floating-group/floating-group.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/floating-group/floating-group.component.ts
new file mode 100644 (file)
index 0000000..da84179
--- /dev/null
@@ -0,0 +1,83 @@
+import {Pager} from '@eg/share/util/pager';
+import {Component, Input, ViewChild, OnInit} from '@angular/core';
+import {Router, ActivatedRoute} from '@angular/router';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {GridDataSource} from '@eg/share/grid/grid';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {OrgService} from '@eg/core/org.service';
+import {PermService} from '@eg/core/perm.service';
+import {AuthService} from '@eg/core/auth.service';
+import {AdminPageComponent} from '../../../share/admin-page/admin-page.component';
+
+@Component({
+    templateUrl: './floating-group.component.html'
+})
+
+export class FloatingGroupComponent extends AdminPageComponent implements OnInit {
+
+    idlClass = 'cfg';
+
+    gridDataSource: GridDataSource = new GridDataSource();
+
+    @ViewChild('grid', {static: true}) grid: GridComponent;
+
+    constructor(
+        route: ActivatedRoute,
+        idl: IdlService,
+        org: OrgService,
+        auth: AuthService,
+        pcrud: PcrudService,
+        perm: PermService,
+        toast: ToastService,
+        private router: Router
+    ) {
+        super(route, idl, org, auth, pcrud, perm, toast);
+    }
+
+    ngOnInit() {
+        super.ngOnInit();
+        this.gridDataSource.getRows = (pager: Pager, sort: any[]) => {
+
+            const orderBy: any = {};
+            if (sort.length) {
+                orderBy.cfg = sort[0].name + ' ' + sort[0].dir;
+            }
+
+            const searchOps = {
+                offset: pager.offset,
+                limit: pager.limit,
+                order_by: orderBy
+            };
+
+            return this.pcrud.retrieveAll('cfg', searchOps);
+        };
+
+        this.grid.onRowActivate.subscribe(
+            (idlThing: IdlObject) => {
+                const idToEdit = idlThing.id();
+                this.navigateToEditPage(idToEdit);
+            }
+        );
+    }
+
+    editSelected = (floatingGroups: IdlObject[]) => {
+        const idToEdit = floatingGroups[0].id();
+        this.navigateToEditPage(idToEdit);
+    }
+
+    deleteSelected = (floatingGroups: IdlObject[]) => {
+        super.deleteSelected(floatingGroups);
+    }
+
+    navigateToEditPage(id: any) {
+        this.router.navigate(['/staff/admin/server/config/floating_group/' + id]);
+    }
+
+    // this was left mostly blank to ensure a modal does not open for edits
+    showEditDialog(idlThing: IdlObject): Promise<any> {
+        return;
+    }
+
+ }
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/server/floating-group/floating-group.module.ts b/Open-ILS/src/eg2/src/app/staff/admin/server/floating-group/floating-group.module.ts
new file mode 100644 (file)
index 0000000..ff26765
--- /dev/null
@@ -0,0 +1,23 @@
+import {NgModule} from '@angular/core';
+import {AdminCommonModule} from '@eg/staff/admin/common.module';
+import {FloatingGroupComponent} from './floating-group.component';
+import {EditFloatingGroupComponent} from './edit-floating-group.component';
+import {FloatingGroupRoutingModule} from './floating-group-routing.module';
+
+@NgModule({
+  declarations: [
+    FloatingGroupComponent,
+    EditFloatingGroupComponent
+  ],
+  imports: [
+    AdminCommonModule,
+    FloatingGroupRoutingModule
+  ],
+  exports: [
+  ],
+  providers: [
+  ]
+})
+
+export class FloatingGroupModule {
+}
index 20321b9..ff5a184 100644 (file)
@@ -96,7 +96,12 @@ export class OrgUnitComponent implements OnInit {
                 'admin.server.org_unit.treenode', {org: orgNode}
             ).then(label => treeNode.label = label);
 
-            orgNode.children().forEach(childNode =>
+            // Tree node labels are "name -- shortname".  Sorting
+            // by name suffices and bypasses the need the wait
+            // for all of the labels to interpolate.
+            orgNode.children()
+            .sort((a, b) => a.name() < b.name() ? -1 : 1)
+            .forEach(childNode =>
                 treeNode.children.push(handleNode(childNode))
             );
 
index 6ce9365..55d9aa3 100644 (file)
@@ -13,6 +13,9 @@ const routes: Routes = [{
     path: 'actor/org_unit_type',
     component: OrgUnitTypeComponent
 }, {
+    path: 'config/floating_group',
+    loadChildren: '@eg/staff/admin/server/floating-group/floating-group.module#FloatingGroupModule'
+}, {
     path: 'config/print_template',
     component: PrintTemplateComponent
 }, {
index 9b14137..dbcfb03 100644 (file)
@@ -11,7 +11,7 @@ import {PickupComponent} from './pickup.component';
 import {PullListComponent} from './pull-list.component';
 import {ReturnComponent} from './return.component';
 import {NoTimezoneSetComponent} from './no-timezone-set.component';
-import {PatronService} from '@eg/staff/share/patron.service';
+import {PatronModule} from '@eg/staff/share/patron/patron.module';
 import {BookingResourceBarcodeValidatorDirective} from './booking_resource_validator.directive';
 import {FmRecordEditorModule} from '@eg/share/fm-editor/fm-editor.module';
 import {OrgFamilySelectModule} from '@eg/share/org-family-select/org-family-select.module';
@@ -23,9 +23,9 @@ import {OrgFamilySelectModule} from '@eg/share/org-family-select/org-family-sele
         BookingRoutingModule,
         ReactiveFormsModule,
         FmRecordEditorModule,
-        OrgFamilySelectModule
+        OrgFamilySelectModule,
+        PatronModule
     ],
-    providers: [PatronService],
     declarations: [
         CancelReservationDialogComponent,
         CreateReservationComponent,
index f2fa021..af68e76 100644 (file)
         i18n for="create-email-notify">Notify by email?</label>
       <input type="checkbox" formControlName="emailNotify">
     </div>
+    <div class="form-group row">
+      <label class="col-lg-4 text-right font-weight-bold"
+        i18n for="create-note">Note</label>
+      <input type="text" id="create-note"
+        class="form-control col-lg-7" formControlName="note">
+    </div>
   </form>
   <div class="modal-footer">
     <button (click)="addBresv$().subscribe()" [disabled]="!create.valid" class="btn btn-info" i18n>Confirm reservation</button>
index 0ef212b..bf61b19 100644 (file)
@@ -83,6 +83,7 @@ export class CreateReservationDialogComponent
             'startTime': new FormControl(null, notBeforeMomentValidator(Moment().add('15', 'minutes'))),
             'endTime': new FormControl(),
             'resourceList': new FormControl(),
+            'note': new FormControl(),
         }, [startTimeIsBeforeEndTimeValidator]
         );
         if (this.patronId) {
@@ -111,7 +112,8 @@ export class CreateReservationDialogComponent
                 this.targetResourceType.id,
                 selectedResourceId,
                 this.attributes.filter(Boolean),
-                this.emailNotify
+                this.emailNotify,
+                this.bresvNote
             ).pipe(tap(
                 (success) => {
                     if (success.ilsevent) {
@@ -197,6 +199,10 @@ export class CreateReservationDialogComponent
         return this.create.get('emailNotify').value;
     }
 
+    get bresvNote() {
+        return this.create.get('note').value;
+    }
+
     get patronBarcode() {
         return this.create.get('patronBarcode');
     }
index 028f7cf..076c413 100644 (file)
@@ -2,7 +2,7 @@ import {Component, OnInit, ViewChild, OnDestroy} from '@angular/core';
 import {Router, ActivatedRoute, ParamMap} from '@angular/router';
 import {Subscription, of} from 'rxjs';
 import {single, filter, switchMap, debounceTime, tap} from 'rxjs/operators';
-import {PatronService} from '@eg/staff/share/patron.service';
+import {PatronService} from '@eg/staff/share/patron/patron.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {IdlObject} from '@eg/core/idl.service';
 import {ReservationsGridComponent} from './reservations-grid.component';
index d7a42f4..f37e10e 100644 (file)
@@ -4,7 +4,7 @@ import {FormGroup, FormControl, Validators} from '@angular/forms';
 import {NgbTabChangeEvent, NgbTabset} from '@ng-bootstrap/ng-bootstrap';
 import {Observable, from, of, Subscription} from 'rxjs';
 import { single, switchMap, tap, debounceTime } from 'rxjs/operators';
-import {PatronService} from '@eg/staff/share/patron.service';
+import {PatronService} from '@eg/staff/share/patron/patron.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {IdlObject} from '@eg/core/idl.service';
 import {ReservationsGridComponent} from './reservations-grid.component';
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/authority/authority.module.ts b/Open-ILS/src/eg2/src/app/staff/cat/authority/authority.module.ts
new file mode 100644 (file)
index 0000000..ded954a
--- /dev/null
@@ -0,0 +1,23 @@
+import {NgModule} from '@angular/core';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {CommonWidgetsModule} from '@eg/share/common-widgets.module';
+import {AuthorityRoutingModule} from './routing.module';
+import {MarcEditModule} from '@eg/staff/share/marc-edit/marc-edit.module';
+import {AuthorityMarcEditComponent} from './marc-edit.component';
+
+@NgModule({
+  declarations: [
+    AuthorityMarcEditComponent
+  ],
+  imports: [
+    StaffCommonModule,
+    CommonWidgetsModule,
+    MarcEditModule,
+    AuthorityRoutingModule
+  ],
+  providers: [
+  ]
+})
+
+export class AuthorityModule {
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/authority/marc-edit.component.html b/Open-ILS/src/eg2/src/app/staff/cat/authority/marc-edit.component.html
new file mode 100644 (file)
index 0000000..e563e3a
--- /dev/null
@@ -0,0 +1,32 @@
+
+<ng-container *ngIf="!authorityId">
+  <!-- If we don't have an authority ID, prompt the user to enter one -->
+  <eg-staff-banner bannerText="Find Authority Record By ID" i18n-bannerText>
+  </eg-staff-banner>
+
+  <div class="row">
+    <div class="col-lg-6 form-inline">
+      <div class="input-group">
+        <div class="input-group-prepend">
+          <span class="input-group-text" i18n>Authorty Record Id</span>
+        </div>
+        <input type="text" class="form-control" 
+          id='auth-id-input'
+          i18n-placeholder placeholder="Authorty Record Id" 
+          i18n-aria-label aria-label="Authorty Record Id"
+          (keyup.enter)="goToAuthority()" [(ngModel)]="loadId"/>
+        <button class="btn btn-success" (click)="goToAuthority()" i18n>Submit</button>
+      </div>
+    </div>
+  </div>
+</ng-container>
+
+<ng-container *ngIf="authorityId">
+  <eg-staff-banner bannerText="Edit Authority Record #{{authorityId}}" i18n-bannerText>
+  </eg-staff-banner>
+
+  <eg-marc-editor #marcEditor recordType="authority" [recordId]="authorityId">
+  </eg-marc-editor>
+</ng-container>
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/authority/marc-edit.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/authority/marc-edit.component.ts
new file mode 100644 (file)
index 0000000..88f2cd0
--- /dev/null
@@ -0,0 +1,35 @@
+import {Component, OnInit, AfterViewInit, ViewChild, Renderer2} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {MarcSavedEvent} from '@eg/staff/share/marc-edit/editor.component';
+
+@Component({
+  templateUrl: 'marc-edit.component.html'
+})
+export class AuthorityMarcEditComponent implements AfterViewInit {
+
+    authorityId: number;
+
+    // Avoid setting authorityId during lookup because it can
+    // cause the marc editor to load prematurely.
+    loadId: number;
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private renderer: Renderer2) {
+        this.authorityId = +this.route.snapshot.paramMap.get('id');
+    }
+
+    ngAfterViewInit() {
+        if (!this.authorityId) {
+            this.renderer.selectRootElement('#auth-id-input').focus();
+        }
+    }
+
+    goToAuthority() {
+        if (this.loadId) {
+            this.router.navigate([`/staff/cat/authority/edit/${this.loadId}`]);
+        }
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/authority/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/cat/authority/routing.module.ts
new file mode 100644 (file)
index 0000000..cd6b3a1
--- /dev/null
@@ -0,0 +1,20 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {AuthorityMarcEditComponent} from './marc-edit.component';
+
+const routes: Routes = [{
+    path: 'edit',
+    component: AuthorityMarcEditComponent
+  }, {
+    path: 'edit/:id',
+    component: AuthorityMarcEditComponent
+}];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule],
+  providers: []
+})
+
+export class AuthorityRoutingModule {}
+
index a923b46..67fb59b 100644 (file)
@@ -4,6 +4,9 @@ import {RouterModule, Routes} from '@angular/router';
 const routes: Routes = [
   { path: 'vandelay',
     loadChildren: '@eg/staff/cat/vandelay/vandelay.module#VandelayModule'
+  }, {
+    path: 'authority',
+    loadChildren: '@eg/staff/cat/authority/authority.module#AuthorityModule'
   }
 ];
 
index 2f32c22..752557c 100644 (file)
       </a>
     </div>
   </div>
-  <div class="">
+  <div class="pr-1">
     <div ngbDropdown placement="bottom-right">
       <button class="btn btn-light" id="basketActions"
         [disabled]="!basketCount()"
         ngbDropdownToggle i18n>Basket Actions</button>
       <div ngbDropdownMenu aria-labelledby="basketActions">
-      <button class="dropdown-item" 
-        (click)="applyAction('view')" i18n>View Basket</button>
-      <button class="dropdown-item" 
-        (click)="applyAction('hold')" i18n>Place Hold</button>
-      <button class="dropdown-item" 
-        (click)="applyAction('print')" i18n>Print Title Details</button>
-      <button class="dropdown-item" 
-        (click)="applyAction('email')" i18n>Email Title Details</button>
-      <button class="dropdown-item" 
-        (click)="applyAction('bucket')" i18n>Add Basket to Bucket</button>
-      <button class="dropdown-item" 
-        (click)="applyAction('export_marc')" i18n>Export Records</button>
-      <button class="dropdown-item" 
-        (click)="applyAction('clear')" i18n>Clear Basket</button>
+        <button class="dropdown-item"
+          (click)="applyAction('view')" i18n>View Basket</button>
+        <button class="dropdown-item"
+          (click)="applyAction('hold')" i18n>Place Hold</button>
+        <button class="dropdown-item"
+          (click)="applyAction('print')" i18n>Print Title Details</button>
+        <button class="dropdown-item"
+          (click)="applyAction('email')" i18n>Email Title Details</button>
+        <button class="dropdown-item"
+          (click)="applyAction('bucket')" i18n>Add Basket to Bucket</button>
+        <button class="dropdown-item"
+          (click)="applyAction('export_marc')" i18n>Export Records</button>
+        <button class="dropdown-item"
+          (click)="applyAction('clear')" i18n>Clear Basket</button>
+      </div>
     </div>
   </div>
+  <div>
+    <!-- Note this Prefernces links is not specific to Basket handling,
+        but it's here since it allowed for consistent formatting -->
+    <a routerLink="/staff/catalog/prefs" queryParamsHandling="merge">
+      <button class="btn btn-light" i18n>Catalog Preferences</button>
+    </a>
+  </div>
 </div>
index 810b950..9b7d57a 100644 (file)
@@ -1,11 +1,11 @@
 import {NgModule} from '@angular/core';
 import {FmRecordEditorModule} from '@eg/share/fm-editor/fm-editor.module';
 import {StaffCommonModule} from '@eg/staff/common.module';
-import {CatalogCommonModule} from '@eg/share/catalog/catalog-common.module';
 import {CatalogRoutingModule} from './routing.module';
 import {HoldsModule} from '@eg/staff/share/holds/holds.module';
 import {HoldingsModule} from '@eg/staff/share/holdings/holdings.module';
 import {BookingModule} from '@eg/staff/share/booking/booking.module';
+import {PatronModule} from '@eg/staff/share/patron/patron.module';
 import {CatalogComponent} from './catalog.component';
 import {SearchFormComponent} from './search-form.component';
 import {ResultsComponent} from './result/results.component';
@@ -30,6 +30,7 @@ import {CnBrowseComponent} from './cnbrowse.component';
 import {CnBrowseResultsComponent} from './cnbrowse/results.component';
 import {SearchTemplatesComponent} from './search-templates.component';
 import {MarcEditModule} from '@eg/staff/share/marc-edit/marc-edit.module';
+import {PreferencesComponent} from './prefs.component';
 
 @NgModule({
   declarations: [
@@ -54,16 +55,17 @@ import {MarcEditModule} from '@eg/staff/share/marc-edit/marc-edit.module';
     SearchTemplatesComponent,
     CnBrowseComponent,
     OpacViewComponent,
+    PreferencesComponent,
     CnBrowseResultsComponent
   ],
   imports: [
     StaffCommonModule,
     FmRecordEditorModule,
-    CatalogCommonModule,
     CatalogRoutingModule,
     HoldsModule,
     HoldingsModule,
     BookingModule,
+    PatronModule,
     MarcEditModule
   ],
   providers: [
index 86501fc..e46c1b4 100644 (file)
@@ -5,6 +5,7 @@ import {OrgService} from '@eg/core/org.service';
 import {CatalogService} from '@eg/share/catalog/catalog.service';
 import {CatalogUrlService} from '@eg/share/catalog/catalog-url.service';
 import {CatalogSearchContext} from '@eg/share/catalog/search-context';
+import {BibRecordSummary} from '@eg/share/catalog/bib-record.service';
 
 /**
  * Shared bits needed by the staff version of the catalog.
@@ -23,6 +24,9 @@ export class StaffCatalogService {
     // TODO: does unapi support pref-lib for result-page copy counts?
     prefOrg: IdlObject;
 
+    // Default search tab
+    defaultTab: string;
+
     // Cache the currently selected detail record (i.g. catalog/record/123)
     // summary so the record detail component can avoid duplicate fetches
     // during record tab navigation.
@@ -61,7 +65,7 @@ export class StaffCatalogService {
         }
 
         if (!this.searchContext.pager.limit) {
-            this.searchContext.pager.limit = this.defaultSearchLimit || 20;
+            this.searchContext.pager.limit = this.defaultSearchLimit || 10;
         }
     }
 
@@ -119,6 +123,16 @@ export class StaffCatalogService {
         params.ridx = '' + this.routeIndex++; // see comments above
         this.router.navigate(['/staff/catalog/cnbrowse'], {queryParams: params});
     }
+
+    // Params to genreate a new author search based on a reset
+    // clone of the current page params.
+    getAuthorSearchParams(summary: BibRecordSummary): any {
+        const tmpContext = this.cloneContext(this.searchContext);
+        tmpContext.reset();
+        tmpContext.termSearch.fieldClass = ['author'];
+        tmpContext.termSearch.query = [summary.display.author];
+        return this.catUrl.toUrlParams(tmpContext);
+    }
 }
 
 
index 09a1f4e..4a3da08 100644 (file)
 <!-- header, pager, and list of records -->
 <div id="staff-catalog-browse-results-container" *ngIf="browseHasResults()">
 
-  <div class="row mb-2">
-    <div class="col-lg-3">
+  <div class="row mb-2 d-flex">
+    <div class="flex-1"></div>
+    <div>
       <button class="btn btn-primary" (click)="prevPage()">Back</button>
       <button class="btn btn-primary ml-3" (click)="nextPage()">Next</button>
     </div>
   </div>
 
-  <div class="row" *ngFor="let result of results; let idx = index">
-    <div class="col-lg-12" *ngIf="result._bibSummary">
-      <eg-catalog-result-record [summary]="result._bibSummary" 
-        [index]="idx" [callNumber]="result">
-      </eg-catalog-result-record>
+  <ng-container *ngIf="results && results.length">
+    <div class="row mb-3" *ngFor="let rowIdx of rowIndexList">
+      <ng-container *ngFor="let callNumber of resultSlice(rowIdx); let colIdx = index">
+        <ng-container *ngIf="callNumber._bibSummary">
+          <div class="col-lg-4 pt-2 d-flex border"
+            [ngClass]="{'border-primary': isCenter(rowIdx, colIdx)}">
+            <div class="flex-1">
+              <div class="font-weight-bold">
+                {{callNumber.prefix().label()}} {{callNumber.label()}}
+                {{callNumber.suffix().label()}}
+                @ {{orgName(callNumber.owning_lib())}}
+              </div>
+              <div>
+                <a queryParamsHandling="merge"
+                  routerLink="/staff/catalog/record/{{callNumber._bibSummary.id}}">
+                  {{callNumber._bibSummary.display.title}}
+                </a>
+              </div>
+              <div>
+                <a routerLink="/staff/catalog/search"
+                  [queryParams]="getAuthorSearchParams(callNumber._bibSummary)">
+                  {{callNumber._bibSummary.display.author}}
+                </a>
+              </div>
+            </div>
+            <div class="ml-2">
+              <img src="/opac/extras/ac/jacket/small/r/{{callNumber._bibSummary.id}}"/>
+            </div>
+          </div>
+        </ng-container>
+      </ng-container>
     </div>
-  </div>
+  </ng-container>
 
-  <div class="row mb-2">
-    <div class="col-lg-3">
+  <div class="row mb-2 d-flex">
+    <div class="flex-1"></div>
+    <div>
       <button class="btn btn-primary" (click)="prevPage()">Back</button>
       <button class="btn btn-primary ml-3" (click)="nextPage()">Next</button>
     </div>
   </div>
-
 </div>
 
 
index 037b9ea..464f443 100644 (file)
@@ -1,13 +1,15 @@
-import {Component, OnInit, OnDestroy} from '@angular/core';
+import {Component, Input, OnInit, OnDestroy} from '@angular/core';
 import {ActivatedRoute, Router, ParamMap} from '@angular/router';
 import {Subscription} from 'rxjs';
 import {IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
 import {CatalogService} from '@eg/share/catalog/catalog.service';
 import {BibRecordService} from '@eg/share/catalog/bib-record.service';
 import {CatalogUrlService} from '@eg/share/catalog/catalog-url.service';
 import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context';
 import {StaffCatalogService} from '../catalog.service';
 import {BibRecordSummary} from '@eg/share/catalog/bib-record.service';
+import {OrgService} from '@eg/core/org.service';
 
 @Component({
   selector: 'eg-catalog-cn-browse-results',
@@ -15,13 +17,28 @@ import {BibRecordSummary} from '@eg/share/catalog/bib-record.service';
 })
 export class CnBrowseResultsComponent implements OnInit, OnDestroy {
 
+    // If set, this is a bib-focused browse
+    @Input() bibSummary: BibRecordSummary;
+
+    @Input() rowCount = 5;
+    rowIndexList: number[] = [];
+
+    // hard-coded because it requires template changes.
+    colCount = 3;
+
     searchContext: CatalogSearchContext;
-    results: any[];
+    results: any[] = [];
     routeSub: Subscription;
 
+    // When browsing by a specific record, keep tabs on the initial
+    // browse call number.
+    browseCn: string;
+
     constructor(
         private router: Router,
         private route: ActivatedRoute,
+        private org: OrgService,
+        private pcrud: PcrudService,
         private cat: CatalogService,
         private bib: BibRecordService,
         private catUrl: CatalogUrlService,
@@ -30,18 +47,57 @@ export class CnBrowseResultsComponent implements OnInit, OnDestroy {
 
     ngOnInit() {
         this.searchContext = this.staffCat.searchContext;
-        this.routeSub = this.route.queryParamMap.subscribe(
-            (params: ParamMap) => this.browseByUrl(params)
-        );
+
+        for (let idx = 0; idx < this.rowCount; idx++) {
+            this.rowIndexList.push(idx);
+        }
+
+        let promise = Promise.resolve();
+        if (this.bibSummary) {
+            promise = this.getBrowseCallnumber();
+        }
+
+        promise.then(_ => {
+            this.routeSub = this.route.queryParamMap.subscribe(
+                (params: ParamMap) => this.browseByUrl(params)
+            );
+        });
     }
 
     ngOnDestroy() {
         this.routeSub.unsubscribe();
     }
 
+    getBrowseCallnumber(): Promise<any> {
+        let org = this.searchContext.searchOrg.id();
+
+        if (this.searchContext.searchOrg.ou_type().can_have_vols() === 'f') {
+            // If the current search org unit cannot hold volumes, search
+            // across child org units.
+            org = this.org.descendants(this.searchContext.searchOrg, true);
+        }
+
+        return this.pcrud.search('acn',
+            {record: this.bibSummary.id, owning_lib: org},
+            {limit: 1}
+        ).toPromise().then(cn =>
+            this.browseCn = cn ? cn.label() : this.bibSummary.bibCallNumber
+        );
+    }
+
     browseByUrl(params: ParamMap): void {
         this.catUrl.applyUrlParams(this.searchContext, params);
+        this.getBrowseResults();
+    }
+
+    getBrowseResults() {
         const cbs = this.searchContext.cnBrowseSearch;
+        cbs.limit = this.rowCount * this.colCount;
+
+        if (this.browseCn) {
+            // Override any call number browse URL parameters
+            cbs.value = this.browseCn;
+        }
 
         if (cbs.isSearchable()) {
             this.results = [];
@@ -100,12 +156,23 @@ export class CnBrowseResultsComponent implements OnInit, OnDestroy {
 
     prevPage() {
         this.searchContext.cnBrowseSearch.offset--;
-        this.staffCat.cnBrowse();
+        if (this.bibSummary) {
+            // Browse without navigation
+            this.getBrowseResults();
+        } else {
+            this.staffCat.cnBrowse();
+        }
+
     }
 
     nextPage() {
         this.searchContext.cnBrowseSearch.offset++;
-        this.staffCat.cnBrowse();
+        if (this.bibSummary) {
+            // Browse without navigation
+            this.getBrowseResults();
+        } else {
+            this.staffCat.cnBrowse();
+        }
     }
 
     /**
@@ -117,6 +184,24 @@ export class CnBrowseResultsComponent implements OnInit, OnDestroy {
         this.router.navigate(
             ['/staff/catalog/record/' + summary.id], {queryParams: params});
     }
+
+    resultSlice(rowIdx: number): number[] {
+        const offset = rowIdx * this.colCount;
+        return this.results.slice(offset, offset + this.colCount);
+    }
+
+    isCenter(rowIdx: number, colIdx: number): boolean {
+        const total = this.rowCount * this.colCount;
+        return Math.floor(total / 2) === ((rowIdx * this.colCount) + colIdx);
+    }
+
+    orgName(orgId: number): string {
+        return this.org.get(orgId).shortname();
+    }
+
+    getAuthorSearchParams(summary: BibRecordSummary): any {
+        return this.staffCat.getAuthorSearchParams(summary);
+    }
 }
 
 
index fa04d86..dca200d 100644 (file)
@@ -1,3 +1,7 @@
+
+<eg-patron-search-dialog #patronSearch>
+</eg-patron-search-dialog>
+
 <div class="row">
   <div class="col-lg-4">
     <h3 i18n>Place Hold 
@@ -7,7 +11,7 @@
     </h3>
   </div>
   <div class="col-lg-2 text-right">
-    <button class="btn btn-outline-dark btn-sm" [disabled]="true">
+    <button class="btn btn-outline-dark btn-sm" (click)="searchPatrons()">
       <span class="material-icons mat-icon-in-button align-middle" 
         i18n-title title="Search for Patron">search</span>
       <span class="align-middle" i18n>Search for Patron</span>
index 539c434..c1640b0 100644 (file)
@@ -13,6 +13,8 @@ import {StaffCatalogService} from '../catalog.service';
 import {HoldsService, HoldRequest,
     HoldRequestTarget} from '@eg/staff/share/holds/holds.service';
 import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {PatronSearchDialogComponent
+  } from '@eg/staff/share/patron/search-dialog.component';
 
 class HoldContext {
     holdMeta: HoldRequestTarget;
@@ -63,6 +65,9 @@ export class HoldComponent implements OnInit {
     smsEnabled: boolean;
     placeHoldsClicked: boolean;
 
+    @ViewChild('patronSearch', {static: false})
+      patronSearch: PatronSearchDialogComponent;
+
     constructor(
         private router: Router,
         private route: ActivatedRoute,
@@ -398,6 +403,22 @@ export class HoldComponent implements OnInit {
             )
         );
     }
+
+    searchPatrons() {
+        this.patronSearch.open({size: 'xl'}).toPromise().then(
+            patrons => {
+                if (!patrons || patrons.length === 0) { return; }
+
+                const user = patrons[0];
+
+                this.user = user;
+                this.userBarcode =
+                    this.currentUserBarcode = user.card().barcode();
+                user.home_ou(this.org.get(user.home_ou()).id()); // de-flesh
+                this.applyUserSettings();
+            }
+        );
+    }
 }
 
 
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/prefs.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/prefs.component.html
new file mode 100644 (file)
index 0000000..b7ed34e
--- /dev/null
@@ -0,0 +1,84 @@
+<eg-catalog-search-form #searchForm></eg-catalog-search-form>
+
+<eg-staff-banner bannerText="Catalog Preferences"></eg-staff-banner>
+
+<eg-string #successMsg i18n-text text="Setting Update Succeeded"></eg-string>
+<eg-string #failMsg i18n-text text="Setting Update Failed"></eg-string>
+
+<div class="row border-bottom border-secondary p-2 m-2">
+  <div class="col-lg-2 offset-lg-1">
+    <label for="default-lib-selector" class="font-weight-bold" i18n>
+      Default Search Library
+    </label>
+  </div>
+  <div class="col-lg-2">
+    <eg-org-select domId="default-lib-selector" 
+      (onChange)="orgChanged($event, 'eg.search.search_lib')"
+      [applyOrgId]="settings['eg.search.search_lib']">
+    </eg-org-select>
+  </div>
+  <div class="col-lg-6" i18n>
+    The default search library setting determines what library is
+    searched from the advanced search screen and portal page by
+    default. Manual selection of a search library will override it. One
+    recommendation is to set the search library to the highest point you
+    would normally want to search.
+  </div>
+</div>
+
+<div class="row border-bottom border-secondary p-2 m-2">
+  <div class="col-lg-2 offset-lg-1">
+    <label for="pref-lib-selector" class="font-weight-bold" i18n>
+      Preferred Library
+    </label>
+  </div>
+  <div class="col-lg-2">
+    <eg-org-select domId="pref-lib-selector" 
+      (onChange)="orgChanged($event, 'eg.search.pref_lib')"
+      [applyOrgId]="settings['eg.search.pref_lib']">
+    </eg-org-select>
+  </div>
+  <div class="col-lg-6" i18n>
+    The preferred library is used to show copies and URIs regardless
+    of the library searched. One recommendation is to set this to your
+    workstation library so that local copies show up first in search
+    results.
+  </div>
+</div>
+
+<div class="row border-bottom border-secondary p-2 m-2">
+  <div class="col-lg-2 offset-lg-1">
+    <label for="def-pane-selector" class="font-weight-bold" i18n>
+      Default Search Pane
+    </label>
+  </div>
+  <div class="col-lg-2">
+    <eg-combobox [selectedId]="settings['eg.search.adv_pane']"
+      (onChange)="paneChanged($event)">
+      <eg-combobox-entry entryId="advanced" entryLabel="Keyword Search"></eg-combobox-entry>
+      <eg-combobox-entry entryId="numeric" entryLabel="Numeric Search"></eg-combobox-entry>
+      <eg-combobox-entry entryId="expert" entryLabel="MARC Search"></eg-combobox-entry>
+      <eg-combobox-entry entryId="browse" entryLabel="Browse"></eg-combobox-entry>
+      <eg-combobox-entry entryId="cnbrowse" entryLabel="Shelf Browse"></eg-combobox-entry>
+    </eg-combobox>
+  </div>
+  <div class="col-lg-6" i18n>
+    Focus this search tab by default when opening new catalog pages.
+  </div>
+</div>
+
+<div class="row border-bottom border-secondary p-2 m-2">
+  <div class="col-lg-2 offset-lg-1">
+    <label for="pref-lib-selector" class="font-weight-bold" i18n>
+      Search Results Per Page
+    </label>
+  </div>
+  <div class="col-lg-2">
+    <input type="number" min="1" max="100" class="form-control"
+      [(ngModel)]="settings['eg.catalog.results.count']"
+      (change)="countChanged()"/>
+  </div>
+  <div class="col-lg-6" i18n>
+    The number of search results to display per page.
+  </div>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/prefs.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/prefs.component.ts
new file mode 100644 (file)
index 0000000..7d81a07
--- /dev/null
@@ -0,0 +1,73 @@
+import {Component, OnInit, ViewChild} from '@angular/core';
+import {IdlObject} from '@eg/core/idl.service';
+import {StaffCatalogService} from './catalog.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+
+/* Component for managing catalog preferences */
+
+const CATALOG_PREFS = [
+    'eg.search.search_lib',
+    'eg.search.pref_lib',
+    'eg.search.adv_pane',
+    'eg.catalog.results.count'
+];
+
+@Component({
+  templateUrl: 'prefs.component.html'
+})
+export class PreferencesComponent implements OnInit {
+
+    settings: Object = {};
+
+    @ViewChild('successMsg', {static: false}) successMsg: StringComponent;
+    @ViewChild('failMsg', {static: false}) failMsg: StringComponent;
+
+    constructor(
+        private store: ServerStoreService,
+        private toast: ToastService,
+        private staffCat: StaffCatalogService,
+    ) {}
+
+    ngOnInit() {
+        this.staffCat.createContext();
+
+        // Pre-fetched by the resolver.
+        this.store.getItemBatch(CATALOG_PREFS)
+        .then(settings => this.settings = settings);
+    }
+
+    orgChanged(org: IdlObject, setting: string) {
+        const localVar = setting === 'eg.search.search_lib' ?
+            'defaultSearchOrg' : 'prefOrg';
+
+        this.updateValue(setting, org ? org.id() : null)
+        .then(val => this.staffCat[localVar] = val);
+    }
+
+    paneChanged(entry: ComboboxEntry) {
+        this.updateValue('eg.search.adv_pane', entry ? entry.id : null)
+        .then(value => this.staffCat.defaultTab = value);
+    }
+
+    countChanged() {
+        this.updateValue('eg.catalog.results.count',
+            this.settings['eg.catalog.results.count'] || null)
+        .then(value => {
+            this.staffCat.searchContext.pager.limit = value || 20;
+        });
+    }
+
+    updateValue(setting: string, value: any): Promise<any> {
+        const promise = (value === null) ?
+            this.store.removeItem(setting) :
+            this.store.setItem(setting, value);
+
+        return promise
+            .then(_ => this.toast.success(this.successMsg.text))
+            .then(_ => value);
+    }
+}
+
index b3e9a9c..038679a 100644 (file)
@@ -94,10 +94,6 @@ export class RecordPaginationComponent implements OnInit {
 
             return this.refreshSearch().then(ok => {
                 this.index = this.searchContext.indexForResult(this.id);
-                if (this.index === null) {
-                    console.warn(
-                        'No search results found containing the focused record.');
-                }
                 resolve();
             });
         });
@@ -141,17 +137,23 @@ export class RecordPaginationComponent implements OnInit {
             return Promise.resolve();
         }
 
-        const origPager = this.searchContext.pager;
+        const ctx = this.searchContext;
+
+        const origPager = ctx.pager;
         const tmpPager = new Pager();
         tmpPager.limit = limit || 1000;
 
-        this.searchContext.pager = tmpPager;
+        ctx.pager = tmpPager;
 
-        return this.cat.search(this.searchContext)
-        .then(
-            ok => this.searchContext.pager = origPager,
-            notOk => this.searchContext.pager = origPager
-        );
+        // Avoid fetching highlight data for a potentially large
+        // list of record IDs
+        ctx.getHighlightsFor = this.id;
+
+        return this.cat.search(ctx)
+        .then(_ => {
+            ctx.pager = origPager;
+            ctx.getHighlightsFor = null;
+        });
     }
 
     returnToSearch(): void {
index 504aa90..58f0de5 100644 (file)
@@ -38,6 +38,10 @@ export class PartsComponent implements OnInit {
         }
     }
 
+    get recordId(): number {
+        return this.recId;
+    }
+
     constructor(
         private idl: IdlService,
         private org: OrgService,
index 29ac211..a9330c3 100644 (file)
@@ -4,13 +4,20 @@
   </eg-title>
 </ng-container>
 
+<eg-confirm-dialog #pendingChangesDialog
+  i18n-dialogTitle dialogTitle="Unsaved Changes Confirmation" 
+  i18n-dialogBoby  dialogBody="Unsaved changes will be lost.  Continue navigation?">
+</eg-confirm-dialog>
+
 <div id="staff-catalog-record-container">
+  <div id='staff-catalog-bib-summary-container' class='mt-1'>
+    <eg-bib-summary [bibSummary]="summaryForDisplay()">
+    </eg-bib-summary>
+  </div>
   <div class="row ml-0 mr-0">
     <div id='staff-catalog-bib-navigation'>
-      <div *ngIf="searchContext.isSearchable()">
-        <eg-catalog-record-pagination [recordId]="recordId" [recordTab]="recordTab">
-        </eg-catalog-record-pagination>
-      </div>
+      <eg-catalog-record-pagination [recordId]="recordId" [recordTab]="recordTab">
+      </eg-catalog-record-pagination>
     </div>
     <!-- push the actions component to the right -->
     <div class="flex-1"></div>
       </eg-catalog-record-actions>
     </div>
   </div>
-  <div id='staff-catalog-bib-summary-container' class='mt-1'>
-    <eg-bib-summary [bibSummary]="summary">
-    </eg-bib-summary>
-  </div>
   <div id='staff-catalog-bib-tabs-container' class='mt-3'>
     <div class="row">
       <div class="col-lg-12 text-right">
@@ -31,8 +34,9 @@
             (click)="setDefaultTab()" i18n>Set Default View</button>
       </div>
     </div>
-    <ngb-tabset #recordTabs [activeId]="recordTab" (tabChange)="onTabChange($event)">
-      <ngb-tab title="Item Table" i18n-title id="catalog">
+    <ngb-tabset #recordTabs [activeId]="recordTab" 
+      (tabChange)="beforeTabChange($event)">
+      <ngb-tab title="Item Table" i18n-title id="item_table">
         <ng-template ngbTabContent>
           <eg-catalog-copies [recordId]="recordId"></eg-catalog-copies>
         </ng-template>
@@ -41,7 +45,7 @@
       <ngb-tab title="MARC Edit" i18n-title id="marc_edit">
         <ng-template ngbTabContent>
           <div class="mt-3">
-            <eg-marc-editor (recordSaved)="handleMarcRecordSaved()" 
+            <eg-marc-editor #marcEditor (recordSaved)="handleMarcRecordSaved()" 
               [recordId]="recordId"></eg-marc-editor>
           </div>
         </ng-template>
           </eg-catalog-record-conjoined>
         </ng-template>
       </ngb-tab>
-      <ngb-tab title="Patron View" i18n-title id="opac">
+      <ngb-tab title="Shelf Browse" i18n-title id="cnbrowse">
+        <ng-template ngbTabContent>
+          <ng-container *ngIf="summary">
+            <div class="mt-2">
+              <eg-catalog-cn-browse-results [bibSummary]="summary">
+              </eg-catalog-cn-browse-results>
+            </div>
+          </ng-container>
+        </ng-template>
+      </ngb-tab>
+      <ngb-tab title="Patron View" i18n-title id="catalog">
         <ng-template ngbTabContent>
           <eg-opac-record-detail [recordId]="recordId">
           </eg-opac-record-detail>
index 83ce9b3..b900ea8 100644 (file)
@@ -1,4 +1,4 @@
-import {Component, OnInit, Input, ViewChild} from '@angular/core';
+import {Component, OnInit, Input, ViewChild, HostListener} from '@angular/core';
 import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
 import {Router, ActivatedRoute, ParamMap} from '@angular/router';
 import {PcrudService} from '@eg/core/pcrud.service';
@@ -9,6 +9,8 @@ import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.s
 import {StaffCatalogService} from '../catalog.service';
 import {BibSummaryComponent} from '@eg/staff/share/bib-summary/bib-summary.component';
 import {StoreService} from '@eg/core/store.service';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {MarcEditorComponent} from '@eg/staff/share/marc-edit/editor.component';
 
 @Component({
   selector: 'eg-catalog-record',
@@ -21,8 +23,12 @@ export class RecordComponent implements OnInit {
     summary: BibRecordSummary;
     searchContext: CatalogSearchContext;
     @ViewChild('recordTabs', { static: true }) recordTabs: NgbTabset;
+    @ViewChild('marcEditor', {static: false}) marcEditor: MarcEditorComponent;
     defaultTab: string; // eg.cat.default_record_tab
 
+    @ViewChild('pendingChangesDialog', {static: false})
+        pendingChangesDialog: ConfirmDialogComponent;
+
     constructor(
         private router: Router,
         private route: ActivatedRoute,
@@ -38,7 +44,7 @@ export class RecordComponent implements OnInit {
 
         this.defaultTab =
             this.store.getLocalItem('eg.cat.default_record_tab')
-            || 'catalog';
+            || 'item_table';
 
         // Watch for URL record ID changes
         // This includes the initial route.
@@ -52,7 +58,7 @@ export class RecordComponent implements OnInit {
             this.searchContext = this.staffCat.searchContext;
 
             if (!this.recordTab) {
-                this.recordTab = this.defaultTab || 'catalog';
+                this.recordTab = this.defaultTab || 'item_table';
             }
 
             this.loadRecord();
@@ -66,13 +72,54 @@ export class RecordComponent implements OnInit {
 
     // Changing a tab in the UI means changing the route.
     // Changing the route ultimately results in changing the tab.
-    onTabChange(evt: NgbTabChangeEvent) {
-        this.recordTab = evt.nextId;
+    beforeTabChange(evt: NgbTabChangeEvent) {
 
         // prevent tab changing until after route navigation
         evt.preventDefault();
 
-        this.routeToTab();
+        // Protect against tab changes with dirty data.
+        this.canDeactivate().then(ok => {
+            if (ok) {
+                this.recordTab = evt.nextId;
+                this.routeToTab();
+            }
+        });
+    }
+
+    /*
+     * Handle 3 types of navigation which can cause loss of data.
+     * 1. Record detail tab navigation (see also beforeTabChange())
+     * 2. Intra-Angular route navigation away from the record detail page
+     * 3. Browser page unload/reload
+     *
+     * For the #1, and #2, display a eg confirmation dialog.
+     * For #3 use the stock browser onbeforeunload dialog.
+     *
+     * Note in this case a tab change is a route change, but it's one
+     * which does not cause RecordComponent to unload, so it has to be
+     * manually tracked in beforeTabChange().
+     */
+    @HostListener('window:beforeunload', ['$event'])
+    canDeactivate($event?: Event): Promise<boolean> {
+
+        if (this.marcEditor && this.marcEditor.changesPending()) {
+
+            // Each warning dialog clears the current "changes are pending"
+            // flag so the user is not presented with the dialog again
+            // unless new changes are made.
+            this.marcEditor.clearPendingChanges();
+
+            if ($event) { // window.onbeforeunload
+                $event.preventDefault();
+                $event.returnValue = true;
+
+            } else { // tab OR route change.
+                return this.pendingChangesDialog.open().toPromise();
+            }
+
+        } else {
+            return Promise.resolve(true);
+        }
     }
 
     routeToTab() {
@@ -104,6 +151,23 @@ export class RecordComponent implements OnInit {
         });
     }
 
+    // Lets us intercept the summary object and augment it with
+    // search highlight data if/when it becomes available from
+    // an externally executed search.
+    summaryForDisplay(): BibRecordSummary {
+        if (!this.summary) { return null; }
+        const sum = this.summary;
+        const ctx = this.searchContext;
+
+        if (Object.keys(sum.displayHighlights).length === 0) {
+            if (ctx.highlightData[sum.id]) {
+                sum.displayHighlights = ctx.highlightData[sum.id];
+            }
+        }
+
+        return this.summary;
+    }
+
     currentSearchOrg(): IdlObject {
         if (this.staffCat && this.staffCat.searchContext) {
             return this.staffCat.searchContext.searchOrg;
index 1b6dac1..842893c 100644 (file)
@@ -43,18 +43,27 @@ export class CatalogResolver implements Resolve<Promise<any[]>> {
         return this.store.getItemBatch([
             'eg.search.search_lib',
             'eg.search.pref_lib',
+            'eg.search.adv_pane',
+            'eg.catalog.results.count',
             'cat.holdings_show_empty_org',
             'cat.holdings_show_empty',
             'cat.marcedit.stack_subfields',
             'cat.marcedit.flateditor',
             'cat.holdings_show_copies',
             'cat.holdings_show_vols',
+            'opac.staff_saved_search.size',
+            'eg.catalog.search_templates',
             'opac.staff_saved_search.size'
         ]).then(settings => {
             this.staffCat.defaultSearchOrg =
                 this.org.get(settings['eg.search.search_lib']);
             this.staffCat.prefOrg =
                 this.org.get(settings['eg.search.pref_lib']);
+            this.staffCat.defaultTab = settings['eg.search.adv_pane'];
+            if (settings['eg.catalog.results.count']) {
+               this.staffCat.defaultSearchLimit =
+                  Number(settings['eg.catalog.results.count']);
+            }
         });
     }
 }
index 3d753f4..2930138 100644 (file)
@@ -1,7 +1,7 @@
 
 /**
- * Force the jacket image column to consume a consistent amount of 
- * horizontal space, while allowing some room for the browser to 
+ * Force the jacket image column to consume a consistent amount of
+ * horizontal space, while allowing some room for the browser to
  * render the correct aspect ratio.
  */
 .record-jacket-div {
index a27c1bd..65209a0 100644 (file)
@@ -1,8 +1,3 @@
-<!-- 
-  TODO
-  routerLink's
-  egDateFilter's
--->
 
 <div class="col-lg-12 card tight-card mb-2 bg-light">
   <div class="card-body">
@@ -18,8 +13,6 @@
           <input class="pl-1" type='checkbox' [(ngModel)]="isRecordSelected"
             (change)="toggleBasketEntry()"/>
         </label>
-        <!-- XXX hard-coded width so columns align vertically regardless
-             of the presence of a jacket image -->
         <div class="pl-2 record-jacket-div" >
           <ng-container *ngIf="hasMrConstituentRecords(summary)">
             <a routerLink="/staff/catalog/search"
         <div class="flex-1 pl-2">
           <div class="row">
             <div class="col-lg-12 font-weight-bold">
-              <!-- nbsp allows the column to take shape when no value exists -->
               <ng-container *ngIf="hasMrConstituentRecords(summary)">
                   <a routerLink="/staff/catalog/search"
                     [queryParams]="appendFromMrParam(summary)">
-                    {{summary.display.title || '&nbsp;'}}
+                    <eg-bib-display-field [summary]="summary" field="title" 
+                      [usePlaceholder]="true"></eg-bib-display-field>
                   </a>
               </ng-container>
               <ng-container *ngIf="!hasMrConstituentRecords(summary)">
                 <a routerLink="/staff/catalog/record/{{summary.id}}"
                   [queryParams]="currentParams()">
-                  {{summary.display.title || '&nbsp;'}}
+                  <eg-bib-display-field [summary]="summary" field="title" 
+                    [usePlaceholder]="true"></eg-bib-display-field>
                 </a>
               </ng-container>
             </div>
           </div>
           <div class="row pt-2">
             <div class="col-lg-12">
-              <!-- nbsp allows the column to take shape when no value exists -->
               <a routerLink="/staff/catalog/search"
-                  [queryParams]="getAuthorSearchParams(summary)">
-                {{summary.display.author || '&nbsp;'}}
+                [queryParams]="getAuthorSearchParams(summary)">
+                <eg-bib-display-field [summary]="summary" field="author" 
+                  [usePlaceholder]="true"></eg-bib-display-field>
               </a>
             </div>
           </div>
               <ng-container *ngIf="summary.display.physical_description">
                 <!-- [].concat() to avoid modifying the summary arrays -->
                 <div class="pb-1" i18n>Phys. Desc.: 
-                  {{[].concat(summary.display.physical_description).join(', ')}}
+                  <eg-bib-display-field [summary]="summary" 
+                    field="physical_description" joiner=","></eg-bib-display-field>
                 </div>
               </ng-container>
               <ng-container *ngIf="summary.display.edition">
-                <div class="pb-1" i18n>Edition: {{summary.display.edition}}</div>
+                <div class="pb-1" i18n>Edition: 
+                  <eg-bib-display-field [summary]="summary" 
+                    field="edition" joiner=","></eg-bib-display-field>
+                </div>
               </ng-container>
               <ng-container *ngIf="summary.display.publisher || summary.display.pubdate">
                 <!-- note publisher typically includes pubdate -->
                 <ng-container *ngIf="summary.display.publisher; else pubDate">
-                  <div class="pb-1" i18n>Publisher: {{summary.display.publisher}}</div>
+                  <div class="pb-1" i18n>Publisher:
+                  <eg-bib-display-field [summary]="summary" field="publisher">
+                  </eg-bib-display-field>
+                  </div>
                 </ng-container>
                 <ng-template #pubDate>
-                  <div class="pb-1" i18n>Pub Date: {{summary.display.pubdate}}</div>
+                  <div class="pb-1" i18n>Pub Date: 
+                    <eg-bib-display-field [summary]="summary" field="pubdate">
+                    </eg-bib-display-field>
+                  </div>
                 </ng-template>
               </ng-container>
               <ng-container *ngIf="summary.display.isbn">
                 <div class="pb-1" i18n>ISBN: 
-                  {{[].concat(summary.display.isbn).join(', ')}}</div>
+                  <eg-bib-display-field [summary]="summary" 
+                    field="isbn" joiner=","></eg-bib-display-field>
+                </div>
               </ng-container>
               <ng-container *ngIf="summary.display.upc">
                 <div class="pb-1" i18n>UPC: 
-                  {{[].concat(summary.display.upc).join(', ')}}</div>
+                  <eg-bib-display-field [summary]="summary" 
+                    field="upc" joiner=","></eg-bib-display-field>
+                </div>
               </ng-container>
               <ng-container *ngIf="summary.display.issn">
                 <div i18n>ISSN: 
-                  {{[].concat(summary.display.issn).join(', ')}}</div>
+                  <eg-bib-display-field [summary]="summary" 
+                    field="issn" joiner=","></eg-bib-display-field>
+                </div>
               </ng-container>
             </div>
           </div>
index b46e4ca..8cb7f03 100644 (file)
@@ -85,11 +85,7 @@ export class ResultRecordComponent implements OnInit, OnDestroy {
     // Params to genreate a new author search based on a reset
     // clone of the current page params.
     getAuthorSearchParams(summary: BibRecordSummary): any {
-        const tmpContext = this.staffCat.cloneContext(this.searchContext);
-        tmpContext.reset();
-        tmpContext.termSearch.fieldClass = ['author'];
-        tmpContext.termSearch.query = [summary.display.author];
-        return this.catUrl.toUrlParams(tmpContext);
+        return this.staffCat.getAuthorSearchParams(summary);
     }
 
     // Returns the URL parameters for the current page plus the
index 869eff2..50ed791 100644 (file)
@@ -71,9 +71,11 @@ export class ResultsComponent implements OnInit, OnDestroy {
     }
 
     ngOnDestroy() {
-        this.routeSub.unsubscribe();
-        this.searchSub.unsubscribe();
-        this.basketSub.unsubscribe();
+        if (this.routeSub) {
+            this.routeSub.unsubscribe();
+            this.searchSub.unsubscribe();
+            this.basketSub.unsubscribe();
+        }
     }
 
     // Apply the select-all checkbox when all visible records
index e0da65f..85e958b 100644 (file)
@@ -7,6 +7,8 @@ import {CatalogResolver} from './resolver.service';
 import {HoldComponent} from './hold/hold.component';
 import {BrowseComponent} from './browse.component';
 import {CnBrowseComponent} from './cnbrowse.component';
+import {CanDeactivateGuard} from '@eg/share/util/can-deactivate.guard';
+import {PreferencesComponent} from './prefs.component';
 
 const routes: Routes = [{
   path: '',
@@ -23,7 +25,8 @@ const routes: Routes = [{
     component: HoldComponent
   }, {
     path: 'record/:id/:tab',
-    component: RecordComponent
+    component: RecordComponent,
+    canDeactivate: [CanDeactivateGuard]
   }]}, {
     // Browse is a top-level UI
     path: 'browse',
@@ -33,6 +36,10 @@ const routes: Routes = [{
     path: 'cnbrowse',
     component: CnBrowseComponent,
     resolve: {catResolver : CatalogResolver}
+  }, {
+    path: 'prefs',
+    component: PreferencesComponent,
+    resolve: {catResolver : CatalogResolver}
   }
 ];
 
index d032f3d..920b008 100644 (file)
@@ -1,7 +1,15 @@
-<!--
-TODO focus search input
--->
-<div id='staffcat-search-form' class="row pt-3 pb-3 mb-3">
+<div id='staffcat-search-form'>
+
+  <div *ngIf="!showThyself" class="row pt-1 pb-1 mb-2 pr-2">
+    <div class="col-lg-12 d-flex">
+      <div class="flex-1"></div><!-- push right -->
+      <a (click)="showThyself=true" class="label-with-material-icon no-href" i18n>
+        Show Search Form <span class="material-icons">unfold_more</span>
+      </a>
+    </div>
+  </div>
+  
+  <div *ngIf="showThyself" class="row pt-3 pb-3 mb-3">
   <div class="col-lg-8">
     <ngb-tabset #searchTabs [activeId]="searchTab" (tabChange)="onTabChange($event)">
       <ngb-tab title="Keyword Search" i18n-title id="term">
@@ -27,6 +35,7 @@ TODO focus search input
             </div>
             <div class="col-lg-2 pl-0 pr-2">
               <select class="form-control" 
+                (change)="preventBogusCombos(idx)"
                 [(ngModel)]="context.termSearch.fieldClass[idx]">
                 <option i18n value='keyword'>Keyword</option>
                 <option i18n value='title'>Title</option>
@@ -42,8 +51,10 @@ TODO focus search input
                 <option i18n value='contains'>Contains</option>
                 <option i18n value='nocontains'>Does not contain</option>
                 <option i18n value='phrase'>Contains phrase</option>
-                <option i18n value='exact'>Matches exactly</option>
-                <option i18n value='starts'>Starts with</option>
+                <option [disabled]="context.termSearch.fieldClass[idx]=='keyword'"
+                  i18n value='exact'>Matches exactly</option>
+                <option [disabled]="context.termSearch.fieldClass[idx]=='keyword'"
+                  i18n value='starts'>Starts with</option>
               </select>
             </div>
             <div class="col-lg-4 pl-0 pr-2">
@@ -351,5 +362,6 @@ TODO focus search input
       </div>
     </div>
   </div>
+  </div>
 </div>
 
index 84dd830..043adb0 100644 (file)
@@ -1,5 +1,5 @@
 import {Component, OnInit, AfterViewInit, Renderer2} from '@angular/core';
-import {ActivatedRoute} from '@angular/router';
+import {Router, ActivatedRoute, NavigationEnd} from '@angular/router';
 import {IdlObject} from '@eg/core/idl.service';
 import {OrgService} from '@eg/core/org.service';
 import {CatalogService} from '@eg/share/catalog/catalog.service';
@@ -7,6 +7,13 @@ import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search
 import {StaffCatalogService} from './catalog.service';
 import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
 
+// Maps opac-style default tab names to local tab names.
+const LEGACY_TAB_NAME_MAP = {
+    expert: 'marc',
+    numeric: 'ident',
+    advanced: 'term'
+};
+
 @Component({
   selector: 'eg-catalog-search-form',
   styleUrls: ['search-form.component.css'],
@@ -21,8 +28,12 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
     copyLocations: IdlObject[];
     searchTab: string;
 
+    // Display the full form if true, otherwise display the expandy.
+    showThyself = true;
+
     constructor(
         private renderer: Renderer2,
+        private router: Router,
         private route: ActivatedRoute,
         private org: OrgService,
         private cat: CatalogService,
@@ -39,6 +50,16 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
                 this.searchTab = params.searchTab;
             }
         });
+
+        this.router.events.subscribe(routeEvent => {
+            if (routeEvent instanceof NavigationEnd) {
+                if (routeEvent.url.match(/catalog\/record/)) {
+                    this.showThyself = false;
+                } else {
+                    this.showThyself = true;
+                }
+            }
+        });
     }
 
     ngOnInit() {
@@ -73,9 +94,18 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
                     this.searchTab = 'ident';
                 } else if (this.context.browseSearch.isSearchable()) {
                     this.searchTab = 'browse';
-                } else {
-                    // Default tab
+                } else if (this.context.termSearch.isSearchable()) {
                     this.searchTab = 'term';
+
+                } else {
+
+                    this.searchTab =
+                        LEGACY_TAB_NAME_MAP[this.staffCat.defaultTab]
+                        || this.staffCat.defaultTab || 'term';
+
+                }
+
+                if (this.searchTab === 'term') {
                     this.refreshCopyLocations();
                 }
             }
@@ -239,6 +269,16 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
     searchIsActive(): boolean {
         return this.context.searchState === CatalogSearchState.SEARCHING;
     }
+
+    // It's possible to chose invalid combos depending on the order of selection
+    preventBogusCombos(idx: number) {
+        if (this.context.termSearch.fieldClass[idx] === 'keyword') {
+            const op = this.context.termSearch.matchOp[idx];
+            if (op === 'exact' || op === 'starts') {
+                this.context.termSearch.matchOp[idx] = 'contains';
+            }
+        }
+    }
 }
 
 
index 8a1eb7d..4697450 100644 (file)
@@ -62,10 +62,9 @@ export class SearchTemplatesComponent extends DialogComponent implements OnInit
 
     ngOnInit() {
         this.context = this.staffCat.searchContext;
-        console.log('ngOnInit() with selected = ', this.staffCat.selectedTemplate);
 
-        this.org.settings('opac.staff_saved_search.size').then(sets => {
-            const size = sets['opac.staff_saved_search.size'] || 0;
+        this.serverStore.getItem('opac.staff_saved_search.size')
+        .then(size => {
             if (!size) { return; }
 
             this.recentSearchesCount = Number(size);
index c0fe41b..1d641e4 100644 (file)
@@ -3,6 +3,7 @@ import {EgCommonModule} from '@eg/common.module';
 import {CommonWidgetsModule} from '@eg/share/common-widgets.module';
 import {AudioService} from '@eg/share/util/audio.service';
 import {GridModule} from '@eg/share/grid/grid.module';
+import {CatalogCommonModule} from '@eg/share/catalog/catalog-common.module';
 import {StaffBannerComponent} from './share/staff-banner.component';
 import {AccessKeyDirective} from '@eg/share/accesskey/accesskey.directive';
 import {AccessKeyService} from '@eg/share/accesskey/accesskey.service';
@@ -39,12 +40,14 @@ import {PatronBarcodeValidatorDirective} from '@eg/share/validators/patron_barco
   imports: [
     EgCommonModule,
     CommonWidgetsModule,
-    GridModule
+    GridModule,
+    CatalogCommonModule
   ],
   exports: [
     EgCommonModule,
     CommonWidgetsModule,
     GridModule,
+    CatalogCommonModule,
     StaffBannerComponent,
     AccessKeyDirective,
     AccessKeyInfoComponent,
index 265368a..5310b5b 100644 (file)
             <span class="material-icons">lock</span>
             <span i18n>Manage Authorities</span>
           </a>
-          <a href="/eg/staff/cat/catalog/retrieve_by_authority_id" class="dropdown-item">
+          <a routerLink="/staff/cat/authority/edit" class="dropdown-item">
             <span class="material-icons">collections</span>
             <span i18n>Retrieve Authority Record by ID</span>
           </a>
index f143727..5627f76 100644 (file)
@@ -1,12 +1,15 @@
-import {Component, OnInit, ViewChild} from '@angular/core';
+import {Component, OnInit, OnDestroy, ViewChild} from '@angular/core';
 import {ActivatedRoute, Router} from '@angular/router';
 import {Location} from '@angular/common';
+import {Subscription} from 'rxjs';
 import {OrgService} from '@eg/core/org.service';
 import {AuthService} from '@eg/core/auth.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {LocaleService} from '@eg/core/locale.service';
 import {PrintService} from '@eg/share/print/print.service';
 import {StoreService} from '@eg/core/store.service';
+import {NetRequest, NetService} from '@eg/core/net.service';
+import {OpChangeComponent} from '@eg/staff/share/op-change/op-change.component';
 
 @Component({
     selector: 'eg-staff-nav-bar',
@@ -14,7 +17,7 @@ import {StoreService} from '@eg/core/store.service';
     templateUrl: 'nav.component.html'
 })
 
-export class StaffNavComponent implements OnInit {
+export class StaffNavComponent implements OnInit, OnDestroy {
 
     // Locales that have Angular staff translations
     locales: any[];
@@ -23,9 +26,13 @@ export class StaffNavComponent implements OnInit {
     // When active, show a link to the experimental Angular staff catalog
     showAngularCatalog: boolean;
 
+    @ViewChild('navOpChange', {static: false}) opChange: OpChangeComponent;
+    permFailedSub: Subscription;
+
     constructor(
         private router: Router,
         private store: StoreService,
+        private net: NetService,
         private org: OrgService,
         private auth: AuthService,
         private pcrud: PcrudService,
@@ -54,6 +61,19 @@ export class StaffNavComponent implements OnInit {
             .then(settings => this.showAngularCatalog =
                 Boolean(settings['ui.staff.angular_catalog.enabled']));
         }
+
+        // Wire up our op-change component as the general purpose
+        // permission failed handler.
+        this.net.permFailedHasHandler = true;
+        this.permFailedSub =
+            this.net.permFailed$.subscribe(
+                (req: NetRequest) => this.opChange.escalateRequest(req));
+    }
+
+    ngOnDestroy() {
+        if (this.permFailedSub) {
+            this.permFailedSub.unsubscribe();
+        }
     }
 
     user() {
index 00bc67a..e0ae6c3 100644 (file)
@@ -33,7 +33,7 @@
   <ng-container *ngTemplateOutlet="helpTemplate"></ng-container>
 </ng-container>
 
-<eg-grid #grid idlClass="{{idlClass}}" [dataSource]="dataSource" 
+<eg-grid #grid idlClass="{{idlClass}}" [dataSource]="dataSource" hideFields="{{hideGridFields}}"
     [sortable]="true" persistKey="{{persistKey}}">
   <eg-grid-toolbar-button [disabled]="!canCreate" 
     label="New {{idlClassDef.label}}" i18n-label (onClick)="createNew()">
@@ -48,7 +48,7 @@
 </eg-grid>
 
 <eg-fm-record-editor #editDialog idlClass="{{idlClass}}" 
-    [fieldOptions]="fieldOptions"
+    [fieldOptions]="fieldOptions" [defaultNewRecord]="defaultNewRecord"
     [preloadLinkedValues]="true" readonlyFields="{{readonlyFields}}">
 </eg-fm-record-editor>
 
index 76c14cd..cb52da7 100644 (file)
@@ -42,6 +42,11 @@ export class AdminPageComponent implements OnInit {
     // Size of create/edito dialog.  Uses large by default.
     @Input() dialogSize: 'sm' | 'lg' = 'lg';
 
+    // comma-separated list of fields to hide.
+    // This does not imply all other fields should be visible, only that
+    // the selected fields will be hidden.
+    @Input() hideGridFields: string;
+
     // If an org unit field is specified, an org unit filter
     // is added to the top of the page.
     @Input() orgField: string;
@@ -75,6 +80,10 @@ export class AdminPageComponent implements OnInit {
     // Override field options for create/edit dialog
     @Input() fieldOptions: {[field: string]: FmFieldOptions};
 
+    // Override default values for fm-editor
+    @Input() defaultNewRecord: IdlObject;
+
+
     @ViewChild('grid', { static: true }) grid: GridComponent;
     @ViewChild('editDialog', { static: true }) editDialog: FmRecordEditorComponent;
     @ViewChild('successString', { static: true }) successString: StringComponent;
index 45345d2..3fd9558 100644 (file)
           <li class="list-group-item">
             <div class="d-flex">
               <div class="flex-1 font-weight-bold" i18n>Title:</div>
-              <div class="flex-3">{{summary.display.title}}</div>
+              <div class="flex-3">
+                <eg-bib-display-field [summary]="summary" field="title">
+                </eg-bib-display-field>
+              </div>
               <div class="flex-1 font-weight-bold pl-1" i18n>Edition:</div>
               <div class="flex-1">{{summary.display.edition}}</div>
               <div class="flex-1 font-weight-bold" i18n>TCN:</div>
index 2c59548..de4d29c 100644 (file)
@@ -9,7 +9,7 @@
       </ng-container>
       <span *ngIf="fromBibQueue" i18n>Add Records from queue #{{fromBibQueue}} to Bucket</span>
     </h4>
-    <button type="button" class="close" 
+    <button type="button" class="close"
       i18n-aria-label aria-label="Close" (click)="close()">
       <span aria-hidden="true">&times;</span>
     </button>
     <div class="row">
       <div class="col-lg-3 font-weight-bold" i18n>Name of existing bucket</div>
       <div class="col-lg-5">
-        <eg-combobox [entries]="formatBucketEntries()" 
+        <eg-combobox [entries]="formatBucketEntries()"
           (onChange)="bucketChanged($event)"
           placeholder="Existing Bucket..." i18n-placeholder>
         </eg-combobox>
       </div>
       <div class="col-lg-4">
-        <button class="btn btn-info" (click)="addToSelected()" i18n 
+        <button class="btn btn-info" (click)="addToSelected()" i18n
           [disabled]="!selectedBucket">
           Add To Selected Bucket
         </button>
     <div class="row mt-3">
       <div class="col-lg-3 font-weight-bold" i18n>Name of new bucket</div>
       <div class="col-lg-5">
-        <input type="text" class="form-control" 
+        <input type="text" class="form-control"
           placeholder="New Bucket Name..."
           i18n-placeholder
           [(ngModel)]="newBucketName"/>
       </div>
       <div class="col-lg-4">
-        <button class="btn btn-info" (click)="addToNew()" i18n 
+        <button class="btn btn-info" (click)="addToNew()" i18n
           [disabled]="!newBucketName">
           Add To New Bucket
         </button>
@@ -48,7 +48,7 @@
     <div class="row mt-3">
       <div class="col-lg-3 font-weight-bold" i18n>New bucket description</div>
       <div class="col-lg-5">
-        <textarea size="3" type="text" class="form-control" 
+        <textarea size="3" type="text" class="form-control"
           placeholder="Optional New Bucket Description..."
           i18n-placeholder
           [(ngModel)]="newBucketDesc">
index 5bde3a7..f7709cd 100644 (file)
@@ -1,33 +1,36 @@
 
-
 <eg-string #successMsg
     text="Successfully Holdings" i18n-text></eg-string>
 <eg-string #errorMsg 
     text="Failed To Delete Holdings" i18n-text></eg-string>
 
+<eg-confirm-dialog #confirmOverride
+  i18n-dialogTitle dialogTitle="One or more items could not be deleted. Override?"
+  i18n-dialogBody dialogBody="Reason(s) include: {{deleteEventDesc}}">
+</eg-confirm-dialog>
 
 <ng-template #dialogContent>
-    <div class="modal-header bg-info">
-      <h4 class="modal-title">
-        <span i18n>Delete Holdings</span>
-      </h4>
-      <button type="button" class="close" 
-        i18n-aria-label aria-label="Close" (click)="close()">
-        <span aria-hidden="true">&times;</span>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title">
+      <span i18n>Delete Holdings</span>
+    </h4>
+    <button type="button" class="close" 
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <p i18n>Delete {{numCallNums}} call numbers and {{numCopies}} copies?</p>
+  </div>
+  <div class="modal-footer">
+    <ng-container>
+      <button type="button" class="btn btn-warning" 
+        (click)="close()" i18n>Cancel</button>
+      <button type="button" class="btn btn-success" 
+        (click)="deleteHoldings()" i18n>
+        Delete Holdings
       </button>
-    </div>
-    <div class="modal-body">
-      <p i18n>Delete {{numCallNums}} call numbers and {{numCopies}} copies?</p>
-    </div>
-    <div class="modal-footer">
-      <ng-container>
-        <button type="button" class="btn btn-warning" 
-          (click)="close()" i18n>Cancel</button>
-        <button type="button" class="btn btn-success" 
-          (click)="deleteHoldings()" i18n>
-          Delete Holdings
-        </button>
-      </ng-container>
-    </div>
-  </ng-template>
-  
+    </ng-container>
+  </div>
+</ng-template>
+
index 3941d34..76fbd58 100644 (file)
@@ -2,13 +2,14 @@ import {Component, OnInit, Input, ViewChild, Renderer2} from '@angular/core';
 import {Observable, throwError} from 'rxjs';
 import {IdlObject} from '@eg/core/idl.service';
 import {NetService} from '@eg/core/net.service';
-import {EventService} from '@eg/core/event.service';
+import {EgEvent, EventService} from '@eg/core/event.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {ToastService} from '@eg/share/toast/toast.service';
 import {AuthService} from '@eg/core/auth.service';
 import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
 import {DialogComponent} from '@eg/share/dialog/dialog.component';
 import {StringComponent} from '@eg/share/string/string.component';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
 
 
 /**
@@ -38,6 +39,7 @@ export class DeleteHoldingDialogComponent
     numCopies: number;
     numSucceeded: number;
     numFailed: number;
+    deleteEventDesc: string;
 
     @ViewChild('successMsg', { static: true })
         private successMsg: StringComponent;
@@ -45,6 +47,9 @@ export class DeleteHoldingDialogComponent
     @ViewChild('errorMsg', { static: true })
         private errorMsg: StringComponent;
 
+    @ViewChild('confirmOverride', {static: false})
+        private confirmOverride: ConfirmDialogComponent;
+
     constructor(
         private modal: NgbModal, // required for passing to parent
         private toast: ToastService,
@@ -89,23 +94,28 @@ export class DeleteHoldingDialogComponent
         return super.open(args);
     }
 
-    deleteHoldings() {
+    deleteHoldings(override?: boolean) {
+
+        this.deleteEventDesc = '';
 
-        const flags = {
+        const flags: any = {
             force_delete_copies: this.forceDeleteCopies
         };
 
+        let method = 'open-ils.cat.asset.volume.fleshed.batch.update';
+        if (override) {
+            method = `${method}.override`;
+            flags.events = ['TITLE_LAST_COPY', 'COPY_DELETE_WARNING'];
+        }
+
         this.net.request(
-            'open-ils.cat',
-            'open-ils.cat.asset.volume.fleshed.batch.update.override',
+            'open-ils.cat', method,
             this.auth.token(), this.callNums, 1, flags
         ).toPromise().then(
             result => {
                 const evt = this.evt.parse(result);
                 if (evt) {
-                    console.warn(evt);
-                    this.errorMsg.current().then(msg => this.toast.warning(msg));
-                    this.numFailed++;
+                    this.handleDeleteEvent(evt, override);
                 } else {
                     this.numSucceeded++;
                     this.close(this.numSucceeded > 0);
@@ -118,6 +128,29 @@ export class DeleteHoldingDialogComponent
             }
         );
     }
+
+    handleDeleteEvent(evt: EgEvent, override?: boolean): Promise<any> {
+
+        if (override) { // override failed
+            console.warn(evt);
+            this.numFailed++;
+            return this.errorMsg.current().then(msg => this.toast.warning(msg));
+        }
+
+        this.deleteEventDesc = evt.desc;
+
+        return this.confirmOverride.open().toPromise().then(confirmed => {
+            if (confirmed) {
+                return this.deleteHoldings(true);
+
+            } else {
+                // User canceled the delete confirmation dialog
+                this.numFailed++;
+                this.errorMsg.current().then(msg => this.toast.warning(msg));
+                this.close(this.numSucceeded > 0);
+            }
+        });
+    }
 }
 
 
index 3c703e6..5c91a68 100644 (file)
@@ -8,8 +8,10 @@ import {AuthService} from '@eg/core/auth.service';
 import {EventService} from '@eg/core/event.service';
 
 interface NewCallNumData {
-    owner: number;
+    owner?: number;
     label?: string;
+    fast_add?: boolean;
+    barcode?: string;
 }
 
 @Injectable()
@@ -25,8 +27,8 @@ export class HoldingsService {
     // Open the holdings editor UI in a new browser window/tab.
     spawnAddHoldingsUi(
         recordId: number,               // Bib record ID
-        addToCallNums?: number[],           // Add copies to / modify existing CNs
-        callNumData?: NewCallNumData[],   // Creating new call numbers
+        addToCallNums?: number[],       // Add copies to / modify existing CNs
+        callNumData?: NewCallNumData[], // Creating new call numbers
         hideCopies?: boolean) {         // Hide the copy edit pane
 
         const raw: any[] = [];
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html
new file mode 100644 (file)
index 0000000..03d1eb5
--- /dev/null
@@ -0,0 +1,123 @@
+
+<!-- display a single heading as MARC -->
+<ng-template #fieldAsMarc let-field="field">
+  <span>{{field.tag}} {{field.ind1}} {{field.ind2}}</span>
+  <span *ngFor="let sf of field.subfields">
+    <span class="text-danger" i18n>‡</span>{{sf[0]}} {{sf[1]}}
+  </span>
+</ng-template>
+
+<!-- MARC edit-ception! -->
+<eg-marc-editor-dialog #marcEditDialog recordType="authority">
+</eg-marc-editor-dialog>
+
+<!-- display a single heading as MARC or as the human friendlier string -->
+<ng-template #headingField 
+  let-field="field" let-from="from" let-also="also" let-authId="authId">
+  <button class="btn btn-sm p-1 mr-1" 
+    [ngClass]="{'btn-outline-primary': !(from || also), 'btn-outline-info': (from || also)}"
+    (click)="applyHeading(field, authId)" i18n>Apply</button>
+  <ng-container *ngIf="showAs == 'heading'">
+    <span *ngIf="from" i18n>See From: {{field.heading}}</span>
+    <span *ngIf="also" i18n>See Also: {{field.heading}}</span>
+    <span *ngIf="!from && !also" i18n>{{field.heading}}</span>
+  </ng-container>
+  <ng-container *ngIf="showAs == 'marc'">
+    <ng-container
+      *ngTemplateOutlet="fieldAsMarc;context:{field:field}">
+    </ng-container>
+  </ng-container>
+</ng-template>
+
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title" i18n>Manage Authority Links</h4>
+    <button type="button" class="close"
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <div class="row border-bottom border-secondary p-2 d-flex">
+      <div class="flex-1 font-weight-bold p-1 pl-2 pt-2 ml-2">
+        <div>{{bibField.tag}} {{bibField.ind1}} {{bibField.ind2}}</div>
+
+        <div *ngFor="let sf of bibField.subfields">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" id="search-subfield-{{sf[0]}}" 
+              type="checkbox" [disabled]="!isControlledBibSf(sf[0])"
+              [(ngModel)]="selectedSubfields[sf[0]]" 
+              (change)="getPage(pager.offset)"/>
+
+            <span class="text-danger" i18n>‡</span>
+
+            <label class="form-check-label" for="search-subfield-{{sf[0]}}" i18n>
+              {{sf[0]}} {{sf[1]}}
+            </label>
+          </div>
+        </div>
+      </div>
+      <div class="ml-2 p-1">
+        <div class="mb-1" i18n>Create new authority from this field</div>
+        <div>
+          <button class="btn btn-outline-info" 
+            (click)="createNewAuthority()">Immediately</button>
+          <button class="btn btn-outline-info ml-2" 
+            (click)="createNewAuthority(true)">Create and Edit</button>
+        </div>
+      </div>
+    </div>
+    <div class="row border-bottom border-secondary p-2 d-flex">
+      <div class="flex-1">
+        <button class="btn btn-outline-dark" [disabled]="pager.offset == 0"
+          (click)="getPage(0)" i18n>Start</button>
+        <button class="btn btn-outline-dark ml-2"
+          (click)="getPage(-1)" i18n>Previous</button>
+        <button class="btn btn-outline-dark ml-2"
+          (click)="getPage(1)" i18n>Next</button>
+      </div>
+      <div class="pt-2 mb-2">
+        <div class="form-check form-check-inline">
+          <input class="form-check-input" type="radio" value="heading"
+            [(ngModel)]="showAs" name='show-as-heading' id="show-as-heading">
+          <label class="form-check-label" for="show-as-heading" i18n>Show As Heading</label>
+        </div>
+        <div class="form-check form-check-inline">
+          <input class="form-check-input" type="radio" value="marc"
+            [(ngModel)]="showAs" name='show-as-heading' id="show-as-marc">
+          <label class="form-check-label" for="show-as-marc" i18n>Show As MARC</label>
+        </div>
+      </div>
+    </div>
+    <ul *ngFor="let entry of browseData">
+      <li class="d-flex mt-1">
+        <div class="flex-1">
+          <ng-container
+            *ngTemplateOutlet="headingField;context:{field:entry.main_heading, authId: entry.authority_id}">
+          </ng-container>
+        </div>
+        <div class="font-italic">
+          <a target="_blank" 
+            i18n-title title="Authority Record ID {{entry.authority_id}}"
+            routerLink="/staff/cat/authority/edit/{{entry.authority_id}}">
+            #{{entry.authority_id}}
+          </a>
+        </div>
+      </li>
+      <ul *ngFor="let from of entry.see_froms">
+        <li class="mt-1">
+         <ng-container
+          *ngTemplateOutlet="headingField;context:{field:from, from:true}">
+         </ng-container>
+        </li>
+      </ul>
+      <ul *ngFor="let also of entry.see_alsos">
+        <li class="mt-1">
+         <ng-container
+          *ngTemplateOutlet="headingField;context:{field:also, also:true}">
+         </ng-container>
+        </li>
+      </ul>
+    </ul>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts
new file mode 100644 (file)
index 0000000..0de369f
--- /dev/null
@@ -0,0 +1,202 @@
+import {Component, ViewChild, Input, Output, OnInit, EventEmitter} from '@angular/core';
+import {NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {MarcField} from './marcrecord';
+import {MarcEditContext} from './editor-context';
+import {Pager} from '@eg/share/util/pager';
+import {MarcEditorDialogComponent} from './editor-dialog.component';
+
+/**
+ * MARC Authority Linking Dialog
+ */
+
+@Component({
+  selector: 'eg-authority-linking-dialog',
+  templateUrl: './authority-linking-dialog.component.html'
+})
+
+export class AuthorityLinkingDialogComponent
+    extends DialogComponent implements OnInit {
+
+    @Input() bibField: MarcField;
+    @Input() thesauri: string = null;
+    @Input() controlSet: number = null;
+    @Input() pager: Pager;
+    @Input() context: MarcEditContext;
+
+    browseData: any[] = [];
+
+    // If false, show the raw MARC field data.
+    showAs: 'heading' | 'marc' = 'heading';
+
+    authMeta: any;
+
+    selectedSubfields: string[] = [];
+
+    cni: string; // Control Number Identifier
+
+    @ViewChild('marcEditDialog', {static: false})
+        marcEditDialog: MarcEditorDialogComponent;
+
+    constructor(
+        private modal: NgbModal,
+        private auth: AuthService,
+        private org: OrgService,
+        private pcrud: PcrudService,
+        private net: NetService) {
+        super(modal);
+    }
+
+    ngOnInit() {
+        if (!this.pager) {
+            this.pager = new Pager();
+            this.pager.limit = 5;
+        }
+
+        this.onOpen$.subscribe(_ => this.initData());
+    }
+
+    fieldHash(field?: MarcField): any {
+        if (!field) { field = this.bibField; }
+
+        return {
+            tag: field.tag,
+            ind1: field.ind1,
+            ind2: field.ind2,
+            subfields: field.subfields.map(sf => [sf[0], sf[1]])
+        };
+    }
+
+    initData() {
+
+        this.pager.offset = 0;
+
+        this.org.settings(['cat.marc_control_number_identifier']).then(s => {
+            this.cni = s['cat.marc_control_number_identifier'] ||
+                'Set cat.marc_control_number_identifier in Library Settings';
+        });
+
+        this.pcrud.search('acsbf',
+            {tag: this.bibField.tag},
+            {flesh: 1, flesh_fields: {acsbf: ['authority_field']}},
+            {atomic:  true, anonymous: true}
+
+        ).subscribe(bibMetas => {
+            if (bibMetas.length === 0) { return; }
+
+            let bibMeta;
+            if (this.controlSet) {
+                bibMeta = bibMetas.filter(b =>
+                    this.controlSet === +b.authority_field().control_set());
+            } else {
+                bibMeta = bibMetas[0];
+            }
+
+            if (bibMeta) {
+                this.authMeta = bibMeta.authority_field();
+                this.bibField.subfields.forEach(sf =>
+                    this.selectedSubfields[sf[0]] =
+                        this.isControlledBibSf(sf[0])
+                );
+            }
+
+            this.getPage(0);
+        });
+    }
+
+    getPage(direction: number) {
+        this.browseData = [];
+
+        if (direction > 0) {
+            this.pager.offset++;
+        } else if (direction < 0) {
+            this.pager.offset--;
+        } else {
+            this.pager.offset = 0;
+        }
+
+        const hash = this.fieldHash();
+
+        // Only search the selected subfields
+        hash.subfields =
+            hash.subfields.filter(sf => this.selectedSubfields[sf[0]]);
+
+        if (hash.subfields.length === 0) { return; }
+
+        this.net.request(
+            'open-ils.cat',
+            'open-ils.cat.authority.bib_field.linking_browse',
+            hash, this.pager.limit,
+            this.pager.offset, this.thesauri
+        ).subscribe(entry => this.browseData.push(entry));
+    }
+
+    applyHeading(authField: MarcField, authId?: number) {
+        this.net.request(
+            'open-ils.cat',
+            'open-ils.cat.authority.bib_field.overlay_authority',
+            this.fieldHash(), this.fieldHash(authField), this.controlSet
+        ).subscribe(field => {
+            if (authId) {
+                // If an authId is provided, it means we are using
+                // a main entry heading and we should set the bib
+                // field's subfield 0 to refer to the main entry record.
+                this.setSubfieldZero(authId, field);
+            }
+            this.close(field);
+        });
+    }
+
+    isControlledBibSf(sf: string): boolean {
+        return this.authMeta ?
+            this.authMeta.sf_list().includes(sf) : false;
+    }
+
+    setSubfieldZero(authId: number, bibField?: MarcField) {
+
+        if (!bibField) { bibField = this.bibField; }
+
+        const sfZero = bibField.subfields.filter(sf => sf[0] === '0')[0];
+        if (sfZero) {
+            this.context.deleteSubfield(bibField, sfZero);
+        }
+        this.context.insertSubfield(bibField,
+            ['0', `(${this.cni})${authId}`, bibField.subfields.length]);
+
+        // Reset the validation state.
+        bibField.authChecked = null;
+        bibField.authValid = null;
+    }
+
+    createNewAuthority(editFirst?: boolean) {
+
+        const method = editFirst ?
+            'open-ils.cat.authority.record.create_from_bib.readonly' :
+            'open-ils.cat.authority.record.create_from_bib';
+
+        this.net.request(
+            'open-ils.cat', method,
+            this.fieldHash(), this.cni, this.auth.token()
+        ).subscribe(record => {
+            if (editFirst) {
+                this.marcEditDialog.recordXml = record;
+                this.marcEditDialog.open({size: 'xl'})
+                .subscribe(saveEvent => {
+                    if (saveEvent && saveEvent.recordId) {
+                        this.setSubfieldZero(saveEvent.recordId);
+                    }
+                    this.close();
+                });
+            } else {
+                this.setSubfieldZero(record.id());
+                this.close();
+            }
+        });
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css
new file mode 100644 (file)
index 0000000..4778fbe
--- /dev/null
@@ -0,0 +1,26 @@
+
+div[contenteditable] {
+   /* provide plenty of input space */
+   min-width: 2em;
+   /* match BS form-control border color */
+   border: 1px solid rgb(206, 212, 218);
+   /* match BS form-control input height */
+   min-height: calc(1.5em + .75rem + 2px);
+}
+
+.sf-delimiter {
+  /* match angjs color */
+  color: rgb(0, 0, 255)!important;
+  /* snuggle up to my subfield code */
+  margin-right: -0.5rem;
+}
+
+.sf-code {
+  /* match angjs color */
+  color: rgb(0, 0, 255)!important;
+}
+
+.auth-invalid {
+  color: rgb(255, 0, 0)!important;
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html
new file mode 100644 (file)
index 0000000..0fb728b
--- /dev/null
@@ -0,0 +1,50 @@
+
+<!--
+Some context menus have additional static options.
+Track their labels here.
+-->
+<eg-string #add006 text="Add 006" i18n-text></eg-string>
+<eg-string #add007 text="Add 007" i18n-text></eg-string>
+<eg-string #add008 text="Add/Replace 008" i18n-text></eg-string>
+<eg-string #insertBefore text="Insert Field Before" i18n-text></eg-string>
+<eg-string #insertAfter text="Insert Field After" i18n-text></eg-string>
+<eg-string #deleteField text="Delete Field" i18n-text></eg-string>
+
+<ng-container *ngIf="bigText">
+  <div contenteditable
+    id='{{randId}}'
+    spellcheck="false"
+    class="d-inline-block text-dark text-break {{moreClasses}}"
+    [ngClass]="{'auth-invalid': isAuthInvalid()}"
+    [attr.tabindex]="fieldText ? -1 : '0'"
+    [attr.aria-label]="ariaLabel"
+    [egContextMenu]="contextMenuEntries()"
+    (menuItemSelected)="contextMenuChange($event.value)"
+    (keydown)="inputKeyDown($event)"
+    (focus)="selectText()"
+    (blur)="inputBlurred()"
+    (input)="bigTextValueChange()">
+  </div>
+</ng-container>
+
+<ng-container *ngIf="!bigText">
+  <input
+    id='{{randId}}'
+    spellcheck="false"
+    class="text-dark rounded-0 form-control {{moreClasses}}"
+    [ngClass]="{'auth-invalid': isAuthInvalid()}"
+    [size]="inputSize()"
+    [maxlength]="maxLength || ''"
+    [disabled]="fieldText"
+    [attr.tabindex]="fieldText ? -1 : '0'"
+    [attr.aria-label]="ariaLabel"
+    [egContextMenu]="contextMenuEntries()"
+    (menuItemSelected)="contextMenuChange($event.value)"
+    (keydown)="inputKeyDown($event)"
+    (focus)="selectText()"
+    (blur)="inputBlurred()"
+    [ngModel]="getContent()"
+    (ngModelChange)="setContent($event)"
+  />
+</ng-container>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
new file mode 100644 (file)
index 0000000..2b06e60
--- /dev/null
@@ -0,0 +1,637 @@
+import {ElementRef, Component, Input, Output, OnInit, OnDestroy,
+    ViewChild, EventEmitter, AfterViewInit, Renderer2} from '@angular/core';
+import {Subscription} from 'rxjs';
+import {filter} from 'rxjs/operators';
+import {MarcRecord, MarcField, MarcSubfield} from './marcrecord';
+import {MarcEditContext, FieldFocusRequest, MARC_EDITABLE_FIELD_TYPE,
+    TextUndoRedoAction} from './editor-context';
+import {ContextMenuEntry} from '@eg/share/context-menu/context-menu.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {TagTable} from './tagtable.service';
+
+/**
+ * MARC Editable Content Component
+ */
+
+@Component({
+  selector: 'eg-marc-editable-content',
+  templateUrl: './editable-content.component.html',
+  styleUrls: ['./editable-content.component.css']
+})
+
+export class EditableContentComponent
+    implements OnInit, AfterViewInit, OnDestroy {
+
+    @Input() context: MarcEditContext;
+    @Input() field: MarcField;
+    @Input() fieldType: MARC_EDITABLE_FIELD_TYPE = null;
+
+    // read-only field text.  E.g. 'LDR'
+    @Input() fieldText: string = null;
+
+    // array of subfield code and subfield value
+    @Input() subfield: MarcSubfield;
+
+    @Input() fixedFieldCode: string;
+
+    // space-separated list of additional CSS classes to append
+    @Input() moreClasses: string;
+
+    // aria-label text.  This will not be visible in the UI.
+    @Input() ariaLabel: string;
+
+    get record(): MarcRecord { return this.context.record; }
+
+    bigText = false;
+    randId = Math.floor(Math.random() * 100000);
+    editInput: any; // <input/> or <div contenteditable/>
+    maxLength: number = null;
+
+    // Track the load-time content so we know what text value to
+    // track on our undo stack.
+    undoBackToText: string;
+
+    focusSub: Subscription;
+    undoRedoSub: Subscription;
+    isLeader: boolean; // convenience
+
+    // Cache of fixed field menu options
+    ffValues: ContextMenuEntry[] = [];
+
+    // Cache of tag context menu entries
+    tagMenuEntries: ContextMenuEntry[] = [];
+
+    // Track the fixed field value locally since extracting the value
+    // in real time from the record, which adds padding to the text,
+    // causes usability problems.
+    ffValue: string;
+
+    @ViewChild('add006', {static: false}) add006Str: StringComponent;
+    @ViewChild('add007', {static: false}) add007Str: StringComponent;
+    @ViewChild('add008', {static: false}) add008Str: StringComponent;
+    @ViewChild('insertBefore', {static: false}) insertBeforeStr: StringComponent;
+    @ViewChild('insertAfter', {static: false}) insertAfterStr: StringComponent;
+    @ViewChild('deleteField', {static: false}) deleteFieldStr: StringComponent;
+
+    constructor(private renderer: Renderer2) {}
+
+    tt(): TagTable { // for brevity
+        return this.context.tagTable;
+    }
+
+    ngOnInit() {
+        this.setupFieldType();
+    }
+
+    ngOnDestroy() {
+        if (this.focusSub) { this.focusSub.unsubscribe(); }
+        if (this.undoRedoSub) { this.undoRedoSub.unsubscribe(); }
+    }
+
+    watchForFocusRequests() {
+        this.focusSub = this.context.fieldFocusRequest.pipe(
+            filter((req: FieldFocusRequest) => this.focusRequestIsMe(req)))
+        .subscribe((req: FieldFocusRequest) => this.selectText(req));
+    }
+
+    watchForUndoRedoRequests() {
+        this.undoRedoSub = this.context.textUndoRedoRequest.pipe(
+            filter((action: TextUndoRedoAction) => this.focusRequestIsMe(action.position)))
+        .subscribe((action: TextUndoRedoAction) => this.processUndoRedo(action));
+    }
+
+    focusRequestIsMe(req: FieldFocusRequest): boolean {
+        if (req.target !== this.fieldType) { return false; }
+
+        if (this.field) {
+            if (req.fieldId !== this.field.fieldId) { return false; }
+        } else if (req.target === 'ldr') {
+            return this.isLeader;
+        } else if (req.target === 'ffld' &&
+            req.ffCode !== this.fixedFieldCode) {
+            return false;
+        }
+
+        if (req.sfOffset !== undefined &&
+            req.sfOffset !== this.subfield[2]) {
+            // this is not the subfield you are looking for.
+            return false;
+        }
+
+        return true;
+    }
+
+    selectText(req?: FieldFocusRequest) {
+        if (this.bigText) {
+            this.focusBigText();
+        } else {
+            this.editInput.select();
+        }
+
+        if (req) {
+            if (req.newText) {
+                this.setContent(req.newText);
+            }
+        } else {
+
+            // Focus request may have come from keyboard navigation,
+            // clicking, etc.  Model the event as a focus request
+            // so it can be tracked the same.
+            req = {
+                fieldId: this.field ? this.field.fieldId : -1,
+                target: this.fieldType,
+                sfOffset: this.subfield ? this.subfield[2] : undefined,
+                ffCode: this.fixedFieldCode
+            };
+        }
+
+        this.context.lastFocused = req;
+    }
+
+    setupFieldType() {
+        const content = this.getContent();
+        this.undoBackToText = content;
+
+        switch (this.fieldType) {
+            case 'ldr':
+                this.isLeader = true;
+                if (content) { this.maxLength = content.length; }
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            case 'tag':
+                this.maxLength = 3;
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            case 'cfld':
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            case 'ffld':
+                this.applyFFOptions();
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            case 'ind1':
+            case 'ind2':
+                this.maxLength = 1;
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            case 'sfc':
+                this.maxLength = 1;
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            case 'sfv':
+                this.bigText = true;
+                this.watchForFocusRequests();
+                this.watchForUndoRedoRequests();
+                break;
+
+            default:
+                if (this.fieldText) {
+                    this.maxLength = this.fieldText.length;
+                }
+        }
+    }
+
+    applyFFOptions() {
+        return this.tt().getFfFieldMeta(this.fixedFieldCode)
+        .then(fieldMeta => {
+            if (fieldMeta) {
+                this.maxLength = fieldMeta.length || 1;
+            }
+        });
+    }
+
+    // These are served dynamically to handle cases where a tag or
+    // subfield is modified in place.
+    contextMenuEntries(): ContextMenuEntry[] {
+        if (this.isLeader) { return; }
+
+        switch (this.fieldType) {
+            case 'tag':
+                return this.tagContextMenuEntries();
+
+            case 'sfc':
+                return this.tt().getSubfieldCodes(this.field.tag);
+
+            case 'sfv':
+                return this.tt().getSubfieldValues(
+                    this.field.tag, this.subfield[0]);
+
+            case 'ind1':
+            case 'ind2':
+                return this.tt().getIndicatorValues(
+                    this.field.tag, this.fieldType);
+
+            case 'ffld':
+                return this.tt().getFfValues(this.fixedFieldCode);
+        }
+
+        return null;
+    }
+
+    tagContextMenuEntries(): ContextMenuEntry[] {
+
+        // string components may not yet be loaded.
+        if (this.tagMenuEntries.length > 0 || !this.add006Str) {
+            return this.tagMenuEntries;
+        }
+
+        this.tagMenuEntries.push(
+            {label: this.add006Str.text, value: '_add006'},
+            {label: this.add007Str.text, value: '_add007'},
+            {label: this.add008Str.text, value: '_add008'}
+        );
+
+        if (!this.field.isCtrlField) {
+            // Only data field tags get these.
+            this.tagMenuEntries.push(
+                {label: this.insertAfterStr.text,  value: '_insertAfter'},
+                {label: this.insertBeforeStr.text, value: '_insertBefore'}
+            );
+        }
+
+        this.tagMenuEntries.push(
+            {label: this.deleteFieldStr.text,  value: '_deleteField'},
+            {divider: true}
+        );
+
+        this.tt().getFieldTags().forEach(e => this.tagMenuEntries.push(e));
+
+        return this.tagMenuEntries;
+    }
+
+    getContent(): string {
+        if (this.fieldText) { return this.fieldText; } // read-only
+
+        switch (this.fieldType) {
+            case 'ldr': return this.record.leader;
+            case 'cfld': return this.field.data;
+            case 'tag': return this.field.tag;
+            case 'sfc': return this.subfield[0];
+            case 'sfv': return this.subfield[1];
+            case 'ind1': return this.field.ind1;
+            case 'ind2': return this.field.ind2;
+
+            case 'ffld':
+                // When actively editing a fixed field, track its value
+                // in a local variable instead of pulling the value
+                // from record.extractFixedField(), which applies
+                // additional formattting, causing usability problems
+                // (e.g. unexpected spaces).  Once focus is gone, the
+                // view will be updated with the correctly formatted
+                // value.
+
+                if ( this.ffValue === undefined ||
+                    !this.context.lastFocused ||
+                    !this.focusRequestIsMe(this.context.lastFocused)) {
+
+                    this.ffValue =
+                        this.record.extractFixedField(this.fixedFieldCode);
+                }
+                return this.ffValue;
+        }
+        return 'X';
+    }
+
+    setContent(value: string, propagatBigText?: boolean, skipUndoTrack?: boolean) {
+
+        if (this.fieldText) { return; } // read-only text
+
+        switch (this.fieldType) {
+            case 'ldr': this.record.leader = value; break;
+            case 'cfld': this.field.data = value; break;
+            case 'tag': this.field.tag = value; break;
+            case 'sfc': this.subfield[0] = value; break;
+            case 'sfv': this.subfield[1] = value; break;
+            case 'ind1': this.field.ind1 = value; break;
+            case 'ind2': this.field.ind2 = value; break;
+            case 'ffld':
+                // Track locally and propagate to the record.
+                this.ffValue = value;
+                this.record.setFixedField(this.fixedFieldCode, value);
+                break;
+        }
+
+        if (propagatBigText && this.bigText) {
+            // Propagate new content to the bigtext div.
+            // Should only be used when a content change occurrs via
+            // external means (i.e. not from a direct edit of the div).
+            this.editInput.innerText = value;
+        }
+
+        if (!skipUndoTrack) {
+            this.trackTextChangeForUndo(value);
+        }
+    }
+
+    trackTextChangeForUndo(value: string) {
+
+        // Human-driven changes invalidate the redo stack.
+        this.context.redoStack = [];
+
+        const lastUndo = this.context.undoStack[0];
+
+        if (lastUndo
+            && lastUndo instanceof TextUndoRedoAction
+            && lastUndo.textContent === this.undoBackToText
+            && this.focusRequestIsMe(lastUndo.position)) {
+            // Most recent undo entry was a text change event within the
+            // current atomic editing (focused) session for the input.
+            // Nothing else to track.
+            return;
+        }
+
+        const undo = new TextUndoRedoAction();
+        undo.position = this.context.lastFocused;
+        undo.textContent =  this.undoBackToText;
+
+        this.context.addToUndoStack(undo);
+    }
+
+    // Apply the undo or redo action and track its opposite
+    // action on the necessary stack
+    processUndoRedo(action: TextUndoRedoAction) {
+
+        // Undoing a text change
+        const recoverContent = this.getContent();
+        this.setContent(action.textContent, true, true);
+
+        action.textContent = recoverContent;
+        const moveTo = action.isRedo ?
+            this.context.undoStack : this.context.redoStack;
+
+        moveTo.unshift(action);
+    }
+
+    inputBlurred() {
+        // If the text content changed during this focus session,
+        // track the new value as the value the next session of
+        // text edits should return to upon undo.
+        this.undoBackToText = this.getContent();
+    }
+
+    // Propagate editable div content into our record
+    bigTextValueChange() {
+        this.setContent(this.editInput.innerText);
+    }
+
+    ngAfterViewInit() {
+        this.editInput = // numeric id requires [id=...] query selector
+            this.renderer.selectRootElement(`[id='${this.randId}']`);
+
+        // Initialize the editable div
+        this.editInput.innerText = this.getContent();
+    }
+
+    inputSize(): number {
+        if (this.maxLength) {
+            return this.maxLength + 1;
+        }
+        // give at least 2+ chars space and grow with the content
+        return Math.max(2, (this.getContent() || '').length) * 1.1;
+    }
+
+    focusBigText() {
+        const targetNode = this.editInput.firstChild;
+
+        if (!targetNode) {
+            // Div contains no text content, nothing to select
+            return;
+        }
+
+        const range = document.createRange();
+        range.setStart(targetNode, 0);
+        range.setEnd(targetNode, targetNode.length);
+
+        const selection = window.getSelection();
+        selection.removeAllRanges();
+        selection.addRange(range);
+    }
+
+    // Route keydown events to the appropriate handler
+    inputKeyDown(evt: KeyboardEvent) {
+
+        switch (evt.key) {
+            case 'y':
+                if (evt.ctrlKey) { // redo
+                    this.context.requestRedo();
+                    evt.preventDefault();
+                }
+                return;
+
+            case 'z':
+                if (evt.ctrlKey) { // undo
+                    this.context.requestUndo();
+                    evt.preventDefault();
+                }
+                return;
+
+            case 'F6':
+                if (evt.shiftKey) {
+                    // shift+F6 => add 006
+                    this.context.add00X('006');
+                    evt.preventDefault();
+                    evt.stopPropagation();
+                }
+                return;
+
+            case 'F7':
+                if (evt.shiftKey) {
+                    // shift+F7 => add 007
+                    this.context.add00X('007');
+                    evt.preventDefault();
+                    evt.stopPropagation();
+                }
+                return;
+
+            case 'F8':
+                if (evt.shiftKey) {
+                    // shift+F8 => add/replace 008
+                    this.context.insertReplace008();
+                    evt.preventDefault();
+                    evt.stopPropagation();
+                }
+                return;
+        }
+
+        // None of the remaining key combos are supported by the LDR
+        // or fixed field editor.
+        if (this.fieldType === 'ldr' || this.fieldType === 'ffld') { return; }
+
+        switch (evt.key) {
+
+            case 'Enter':
+                if (evt.ctrlKey) {
+                    // ctrl+enter == insert stub field after focused field
+                    // ctrl+shift+enter == insert stub field before focused field
+                    this.context.insertStubField(this.field, evt.shiftKey);
+                }
+
+                evt.preventDefault(); // Bare newlines not allowed.
+                break;
+
+            case 'Delete':
+
+                if (evt.ctrlKey) {
+                    // ctrl+delete == delete whole field
+                    this.context.deleteField(this.field);
+                    evt.preventDefault();
+
+                } else if (evt.shiftKey) {
+
+                    if (this.subfield) {
+                        // shift+delete == delete subfield
+
+                        this.context.deleteSubfield(this.field, this.subfield);
+                    }
+                    // prevent any shift-delete from bubbling up becuase
+                    // unexpected stuff will be deleted.
+                    evt.preventDefault();
+                }
+
+                break;
+
+            case 'ArrowDown':
+
+                if (evt.ctrlKey) {
+                    // ctrl+down == copy current field down one
+                    this.context.insertField(
+                        this.field, this.record.cloneField(this.field));
+                } else {
+                    // avoid dupe focus requests
+                    this.context.focusNextTag(this.field);
+                }
+
+                evt.preventDefault();
+                break;
+
+            case 'ArrowUp':
+
+                if (evt.ctrlKey) {
+                    // ctrl+up == copy current field up one
+                    this.context.insertField(
+                        this.field, this.record.cloneField(this.field), true);
+                } else {
+                    // avoid dupe focus requests
+                    this.context.focusPreviousTag(this.field);
+                }
+
+                // up == move focus to tag of previous field
+                evt.preventDefault();
+                break;
+
+            case 'd': // thunk
+            case 'i':
+                if (evt.ctrlKey) {
+                    // ctrl+i / ctrl+d == insert subfield
+                    const pos = this.subfield ? this.subfield[2] + 1 : 0;
+                    this.context.insertStubSubfield(this.field, pos);
+                    evt.preventDefault();
+                }
+                break;
+        }
+    }
+
+    insertField(before: boolean) {
+
+        const newField = this.record.newField(
+            {tag: '999', subfields: [[' ', '', 0]]});
+
+        if (before) {
+            this.record.insertFieldsBefore(this.field, newField);
+        } else {
+            this.record.insertFieldsAfter(this.field, newField);
+        }
+
+        this.context.requestFieldFocus(
+            {fieldId: newField.fieldId, target: 'tag'});
+    }
+
+    deleteField() {
+        if (!this.context.focusNextTag(this.field)) {
+            this.context.focusPreviousTag(this.field);
+        }
+
+        this.record.deleteFields(this.field);
+    }
+
+    deleteSubfield() {
+        // If subfields remain, focus the previous subfield.
+        // otherwise focus our tag.
+        const sfpos = this.subfield[2] - 1;
+
+        this.field.deleteExactSubfields(this.subfield);
+
+        const focus: FieldFocusRequest = {
+            fieldId: this.field.fieldId, target: 'tag'};
+
+        if (sfpos >= 0) {
+            focus.target = 'sfv';
+            focus.sfOffset = sfpos;
+        }
+
+        this.context.requestFieldFocus(focus);
+    }
+
+    contextMenuChange(value: string) {
+
+        switch (value) {
+            case '_add006': return this.context.add00X('006');
+            case '_add007': return this.context.add00X('007');
+            case '_add008': return this.context.insertReplace008();
+            case '_insertBefore':
+                return this.context.insertStubField(this.field, true);
+            case '_insertAfter':
+                return this.context.insertStubField(this.field);
+            case '_deleteField': return this.context.deleteField(this.field);
+        }
+
+        this.setContent(value, true);
+
+        // Context menus can steal focus.
+        this.context.requestFieldFocus(this.context.lastFocused);
+    }
+
+    isAuthInvalid(): boolean {
+        return (
+            this.fieldType === 'sfv' &&
+            this.field.authChecked &&
+            !this.field.authValid
+        );
+    }
+
+    isAuthValid(): boolean {
+        return (
+            this.fieldType === 'sfv' &&
+            this.field.authChecked &&
+            this.field.authValid
+        );
+    }
+
+    isLastSubfieldValue(): boolean {
+        if (this.fieldType === 'sfv') {
+            const myIdx = this.subfield[2];
+            for (let idx = 0; idx < this.field.subfields.length; idx++) {
+                if (idx > myIdx) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        return false;
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
new file mode 100644 (file)
index 0000000..49ed524
--- /dev/null
@@ -0,0 +1,434 @@
+import {EventEmitter} from '@angular/core';
+import {MarcRecord, MarcField, MarcSubfield} from './marcrecord';
+import {NgbPopover} from '@ng-bootstrap/ng-bootstrap';
+import {TagTable} from './tagtable.service';
+
+/* Per-instance MARC editor context. */
+
+const STUB_DATA_00X = '                                        ';
+
+export type MARC_EDITABLE_FIELD_TYPE =
+    'ldr' | 'tag' | 'cfld' | 'ind1' | 'ind2' | 'sfc' | 'sfv' | 'ffld';
+
+export interface FieldFocusRequest {
+    fieldId: number;
+    target: MARC_EDITABLE_FIELD_TYPE;
+    sfOffset?: number; // focus a specific subfield by its offset
+    ffCode?: string; // fixed field code
+
+    // If set, an external source wants to modify the text content
+    // of an editable component (in a way that retains undo/redo
+    // functionality).
+    newText?: string;
+}
+
+export class UndoRedoAction {
+    // Which point in the record was modified.
+    position: FieldFocusRequest;
+
+    // Which stack do we toss this on once it's been applied?
+    isRedo: boolean;
+
+    // Grouped actions are tracked as multiple undo / redo actions, but
+    // are done and un-done as a unit.
+    groupSize?: number;
+}
+
+export class TextUndoRedoAction extends UndoRedoAction {
+    textContent: string;
+}
+
+export class StructUndoRedoAction extends UndoRedoAction {
+    /* Add or remove a part of the record (field, subfield, etc.) */
+
+    // Does this action track an addition or deletion.
+    wasAddition: boolean;
+
+    // Field to add/delete or field to modify for subfield adds/deletes
+    field: MarcField;
+
+    // If this is a subfield modification.
+    subfield: MarcSubfield;
+
+    // Position preceding the modified position to mark the position
+    // of deletion recovery.
+    prevPosition: FieldFocusRequest;
+
+    // Location of the cursor at time of initial action.
+    prevFocus: FieldFocusRequest;
+}
+
+
+export class MarcEditContext {
+
+    recordChange: EventEmitter<MarcRecord>;
+    fieldFocusRequest: EventEmitter<FieldFocusRequest>;
+    textUndoRedoRequest: EventEmitter<TextUndoRedoAction>;
+    recordType: 'biblio' | 'authority' = 'biblio';
+
+    lastFocused: FieldFocusRequest = null;
+
+    undoStack: UndoRedoAction[] = [];
+    redoStack: UndoRedoAction[] = [];
+
+    tagTable: TagTable;
+
+    // True if any changes have been made.
+    // For the 'rich' editor, this is any un-do-able actions.
+    // For the text edtior it's any text change.
+    changesPending: boolean;
+
+    private _record: MarcRecord;
+    set record(r: MarcRecord) {
+        if (r !== this._record) {
+            this._record = r;
+            this._record.stampFieldIds();
+            this.recordChange.emit(r);
+        }
+    }
+
+    get record(): MarcRecord {
+        return this._record;
+    }
+
+    constructor() {
+        this.recordChange = new EventEmitter<MarcRecord>();
+        this.fieldFocusRequest = new EventEmitter<FieldFocusRequest>();
+        this.textUndoRedoRequest = new EventEmitter<TextUndoRedoAction>();
+    }
+
+    requestFieldFocus(req: FieldFocusRequest) {
+        // timeout allows for new components to be built before the
+        // focus request is emitted.
+        if (req) {
+            setTimeout(() => this.fieldFocusRequest.emit(req));
+        }
+    }
+
+    resetUndos() {
+        this.undoStack = [];
+        this.redoStack = [];
+    }
+
+    requestUndo() {
+        let remaining = null;
+
+        do {
+            const action = this.undoStack.shift();
+            if (!action) { return; }
+
+            if (remaining === null) {
+                remaining = action.groupSize || 1;
+            }
+            remaining--;
+
+            action.isRedo = false;
+            this.distributeUndoRedo(action);
+
+        } while (remaining > 0);
+    }
+
+    requestRedo() {
+        let remaining = null;
+
+        do {
+            const action = this.redoStack.shift();
+            if (!action) { return; }
+
+            if (remaining === null) {
+                remaining = action.groupSize || 1;
+            }
+            remaining--;
+
+            action.isRedo = true;
+            this.distributeUndoRedo(action);
+
+        } while (remaining > 0);
+    }
+
+    // Calculate stack action count taking groupSize (atomic action
+    // sets) into consideration.
+    stackCount(stack: UndoRedoAction[]): number {
+        let size = 0;
+        let skip = 0;
+
+        stack.forEach(action => {
+            if (action.groupSize > 1) {
+                if (skip) { return; }
+                skip = 1;
+            } else {
+                skip = 0;
+            }
+            size++;
+        });
+
+        return size;
+    }
+
+    undoCount(): number {
+        return this.stackCount(this.undoStack);
+    }
+
+    redoCount(): number {
+        return this.stackCount(this.redoStack);
+    }
+
+    // Stamp the most recent 'size' entries in the undo stack
+    // as being an atomic undo/redo set.
+    setUndoGroupSize(size: number) {
+        for (let idx = 0; idx < size; idx++) {
+            if (this.undoStack[idx]) {
+                this.undoStack[idx].groupSize = size;
+            }
+        }
+    }
+
+    distributeUndoRedo(action: UndoRedoAction) {
+        if (action instanceof TextUndoRedoAction) {
+            // Let the editable content component handle it.
+            this.textUndoRedoRequest.emit(action);
+        } else {
+            // Manage structural changes within
+            this.handleStructuralUndoRedo(action as StructUndoRedoAction);
+        }
+    }
+
+    addToUndoStack(action: UndoRedoAction) {
+        this.changesPending = true;
+        this.undoStack.unshift(action);
+    }
+
+    handleStructuralUndoRedo(action: StructUndoRedoAction) {
+
+        if (action.wasAddition) {
+            // Remove the added field
+
+            if (action.subfield) {
+                const prevPos = action.subfield[2] - 1;
+                action.field.deleteExactSubfields(action.subfield);
+                this.focusSubfield(action.field, prevPos);
+
+            } else {
+                this.record.deleteFields(action.field);
+            }
+
+            // When deleting chunks, always return focus to the
+            // pre-insert position.
+            this.requestFieldFocus(action.prevFocus);
+
+        } else {
+            // Re-insert the removed field and focus it.
+
+            if (action.subfield) {
+
+                this.insertSubfield(action.field, action.subfield, true);
+                this.focusSubfield(action.field, action.subfield[2]);
+
+            } else {
+
+                const fieldId = action.position.fieldId;
+                const prevField =
+                    this.record.getField(action.prevPosition.fieldId);
+
+                this.record.insertFieldsAfter(prevField, action.field);
+
+                // Recover the original fieldId, which gets re-stamped
+                // in this.record.insertFields* calls.
+                action.field.fieldId = fieldId;
+
+                // Focus the newly recovered field.
+                this.requestFieldFocus(action.position);
+            }
+
+            // When inserting chunks, track the location where the
+            // insert was requested so we can return the cursor so we
+            // can return the cursor to the scene of the crime if the
+            // undo is re-done or vice versa.  This is primarily useful
+            // when performing global inserts like add00X, which can be
+            // done without the 00X field itself having focus.
+            action.prevFocus = this.lastFocused;
+        }
+
+        action.wasAddition = !action.wasAddition;
+
+        const moveTo = action.isRedo ? this.undoStack : this.redoStack;
+
+        moveTo.unshift(action);
+    }
+
+    trackStructuralUndo(field: MarcField, isAddition: boolean, subfield?: MarcSubfield) {
+
+        // Human-driven changes invalidate the redo stack.
+        this.redoStack = [];
+
+        const position: FieldFocusRequest = {fieldId: field.fieldId, target: 'tag'};
+
+        let prevPos: FieldFocusRequest = null;
+
+        if (subfield) {
+            position.target = 'sfc';
+            position.sfOffset = subfield[2];
+
+        } else {
+            // No need to track the previous field for subfield mods.
+
+            const prevField = this.record.getPreviousField(field.fieldId);
+            if (prevField) {
+                prevPos = {fieldId: prevField.fieldId, target: 'tag'};
+            }
+        }
+
+        const action = new StructUndoRedoAction();
+        action.field = field;
+        action.subfield = subfield;
+        action.wasAddition = isAddition;
+        action.position = position;
+        action.prevPosition = prevPos;
+
+        // For bulk adds (e.g. add a whole row) the field focused at
+        // time of action will be different than the added field.
+        action.prevFocus = this.lastFocused;
+
+        this.addToUndoStack(action);
+    }
+
+    deleteField(field: MarcField) {
+        this.trackStructuralUndo(field, false);
+
+        if (!this.focusNextTag(field)) {
+            this.focusPreviousTag(field);
+        }
+
+        this.record.deleteFields(field);
+    }
+
+    add00X(tag: string) {
+
+        const field: MarcField =
+            this.record.newField({tag : tag, data : STUB_DATA_00X});
+
+        this.record.insertOrderedFields(field);
+
+        this.trackStructuralUndo(field, true);
+
+        this.focusTag(field);
+    }
+
+    insertReplace008() {
+
+        // delete all of the 008s
+        [].concat(this.record.field('008', true)).forEach(f => {
+            this.trackStructuralUndo(f, false);
+            this.record.deleteFields(f);
+        });
+
+        const field = this.record.newField({
+            tag : '008', data : this.record.generate008()});
+
+        this.record.insertOrderedFields(field);
+
+        this.trackStructuralUndo(field, true);
+
+        this.focusTag(field);
+    }
+
+    // Add stub field before or after the context field
+    insertStubField(field: MarcField, before?: boolean) {
+
+        const newField = this.record.newField(
+            {tag: '999', subfields: [[' ', '', 0]]});
+
+        this.insertField(field, newField, before);
+    }
+
+    insertField(contextField: MarcField, newField: MarcField, before?: boolean) {
+
+        if (before) {
+            this.record.insertFieldsBefore(contextField, newField);
+            this.focusPreviousTag(contextField);
+
+        } else {
+            this.record.insertFieldsAfter(contextField, newField);
+            this.focusNextTag(contextField);
+        }
+
+        this.trackStructuralUndo(newField, true);
+    }
+
+    // Adds a new empty subfield to the provided field at the
+    // requested subfield position
+    insertSubfield(field: MarcField,
+        subfield: MarcSubfield, skipTracking?: boolean) {
+        const position = subfield[2];
+
+        // array index 3 contains that position of the subfield
+        // in the MARC field.  When splicing a new subfield into
+        // the set, be sure the any that come after the new one
+        // have their positions bumped to reflect the shift.
+        field.subfields.forEach(
+            sf => {if (sf[2] >= position) { sf[2]++; }});
+
+        field.subfields.splice(position, 0, subfield);
+
+        if (!skipTracking) {
+            this.focusSubfield(field, position);
+            this.trackStructuralUndo(field, true, subfield);
+        }
+    }
+
+    insertStubSubfield(field: MarcField, position: number) {
+        const newSf: MarcSubfield = [' ', '', position];
+        this.insertSubfield(field, newSf);
+    }
+
+    // Focus the requested subfield by its position.  If its
+    // position is less than zero, focus the field's tag instead.
+    focusSubfield(field: MarcField, position: number) {
+
+        const focus: FieldFocusRequest = {fieldId: field.fieldId, target: 'tag'};
+
+        if (position >= 0) {
+            // Focus the code instead of the value, because attempting to
+            // focus an empty (editable) div results in nothing getting focus.
+            focus.target = 'sfc';
+            focus.sfOffset = position;
+        }
+
+        this.requestFieldFocus(focus);
+    }
+
+    deleteSubfield(field: MarcField, subfield: MarcSubfield) {
+        const sfpos = subfield[2] - 1; // previous subfield
+
+        this.trackStructuralUndo(field, false, subfield);
+
+        field.deleteExactSubfields(subfield);
+
+        this.focusSubfield(field, sfpos);
+    }
+
+    focusTag(field: MarcField) {
+        this.requestFieldFocus({fieldId: field.fieldId, target: 'tag'});
+    }
+
+    // Returns true if the field has a next tag to focus
+    focusNextTag(field: MarcField) {
+        const nextField = this.record.getNextField(field.fieldId);
+        if (nextField) {
+            this.focusTag(nextField);
+            return true;
+        }
+        return false;
+    }
+
+    // Returns true if the field has a previous tag to focus
+    focusPreviousTag(field: MarcField): boolean {
+        const prevField = this.record.getPreviousField(field.fieldId);
+        if (prevField) {
+            this.focusTag(prevField);
+            return true;
+        }
+        return false;
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.html
new file mode 100644 (file)
index 0000000..1fc6efa
--- /dev/null
@@ -0,0 +1,14 @@
+
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title" i18n>MARC Editor</h4>
+    <button type="button" class="close"
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <eg-marc-editor #marcEditor (recordSaved)="handleRecordSaved($event)" 
+      [recordType]="recordType" [recordXml]="recordXml"></eg-marc-editor>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-dialog.component.ts
new file mode 100644 (file)
index 0000000..67e836b
--- /dev/null
@@ -0,0 +1,44 @@
+import {Component, Input, Output, OnInit, EventEmitter} from '@angular/core';
+import {Observable} from 'rxjs';
+import {NetService} from '@eg/core/net.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal, NgbModalRef, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap';
+import {MarcEditContext} from './editor-context';
+
+
+/**
+ * Spawn a MARC editor within a dialog.
+ */
+
+@Component({
+  selector: 'eg-marc-editor-dialog',
+  templateUrl: './editor-dialog.component.html'
+})
+
+export class MarcEditorDialogComponent
+    extends DialogComponent implements OnInit {
+
+    @Input() context: MarcEditContext;
+    @Input() recordXml: string;
+    @Input() recordType: 'biblio' | 'authority' = 'biblio';
+
+    constructor(
+        private modal: NgbModal,
+        private auth: AuthService,
+        private org: OrgService,
+        private pcrud: PcrudService,
+        private net: NetService) {
+        super(modal);
+    }
+
+    ngOnInit() {}
+
+    handleRecordSaved(saved) {
+        this.close(saved);
+    }
+}
+
+
index 55c5af6..95baa07 100644 (file)
 <eg-string #failMsg i18n-text text="Record failed to update"></eg-string>
 
 <div class="row d-flex p-2 m-2">
+
+  <ng-container *ngIf="recordType === 'biblio'">
+    <div class="form-check">
+      <input class="form-check-input" type="checkbox"
+        [(ngModel)]="showFastAdd" id="fast-add-item"/>
+      <label class="form-check-label" for="fast-add-item">
+        Add Item
+      </label>
+    </div>
+  </ng-container>
+
+  <ng-container *ngIf="showFastAdd">
+    <div class="form-inline">
+      <input type="text" class="form-control ml-2" 
+        [(ngModel)]="fastItemLabel" placeholder="Call Number" i18n-placeholder/>
+      <input type="text" class="form-control ml-2" 
+        [(ngModel)]="fastItemBarcode" placeholder="Barcode" i18n-placeholder/>
+    </div>
+  </ng-container>
+
   <div class="flex-1"></div>
-  <div class="mr-2">
-    <eg-combobox #sourceSelector
-      [entries]="sources"
-      placeholder="Select a Source..."
-      i18n-placeholder>
-    </eg-combobox>
+
+  <h3 class="mr-2">
+    <span class="badge badge-light p-2" i18n>
+      Record Type {{record ? record.recordType() : ''}}
+    </span>
+  </h3>
+    
+  <ng-container *ngIf="recordType === 'biblio'">
+    <div class="mr-2">
+      <eg-combobox #sourceSelector
+        [entries]="sources"
+        placeholder="Select a Source..."
+        i18n-placeholder>
+      </eg-combobox>
+    </div>
+  </ng-container>
+
+  <div class="pr-3 mr-3 border-right">
+    <button class="btn btn-success" (click)="saveRecord()"
+      [disabled]="record && record.deleted" i18n>Save Changes</button>
   </div>
 
   <ng-container *ngIf="record && record.id">
     <button *ngIf="record.deleted" class="btn btn-info" 
       [disabled]="inPlaceMode" (click)="undeleteRecord()" i18n>Undelete Record</button>
   </ng-container>
-
-  <button class="btn btn-success ml-2" (click)="saveRecord()" 
-    [disabled]="record && record.deleted" i18n>Save Changes</button>
 </div>
 
-<div class="row">
+<ng-container *ngIf="dataSaving">
+  <div class="row mt-5">
+    <div class="offset-lg-3 col-lg-6">
+      <eg-progress-inline></eg-progress-inline>
+    </div>
+  </div>
+</ng-container>
+
+<div *ngIf="!dataSaving" class="row">
   <div class="col-lg-12">
-    <ngb-tabset [activeId]="editorTab">
-      <ngb-tab title="Enhanced MARC Editor" i18n-title id="rich" *ngIf="!inPlaceMode">
+    <ngb-tabset [activeId]="editorTab" (tabChange)="tabChange($event)">
+      <ngb-tab title="Enhanced MARC Editor" i18n-title id="rich">
         <ng-template ngbTabContent>
-          <div class="alert alert-info mt-3" i18n>
-          Enhanced MARC Editor is not yet implemented.  See the
-          <ng-container *ngIf="record && record.id">
-            <a target="_blank"
-              href="/eg/staff/cat/catalog/record/{{record.id}}/marc_edit">
-              AngularJS MARC Editor.
-            </a>
-          </ng-container>
-          <ng-container *ngIf="!record || !record.id">
-            <a target="_blank" href="/eg/staff/cat/catalog/new_bib">
-              AngularJS MARC Editor.
-            </a>
+          <ng-container *ngIf="context && context.record">
+            <eg-marc-rich-editor [context]="context"></eg-marc-rich-editor>
           </ng-container>
-          </div>
         </ng-template>
       </ngb-tab>
       <ngb-tab title="Flat Text Editor" i18n-title id="flat">
         <ng-template ngbTabContent>
-          <eg-marc-flat-editor></eg-marc-flat-editor>
+          <ng-container *ngIf="context && context.record">
+            <eg-marc-flat-editor [context]="context"></eg-marc-flat-editor>
+          </ng-container>
         </ng-template>
       </ngb-tab>
     </ngb-tabset>
index e25734f..0bc308a 100644 (file)
@@ -6,15 +6,20 @@ import {AuthService} from '@eg/core/auth.service';
 import {OrgService} from '@eg/core/org.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {ToastService} from '@eg/share/toast/toast.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
 import {StringComponent} from '@eg/share/string/string.component';
 import {MarcRecord} from './marcrecord';
 import {ComboboxEntry, ComboboxComponent
   } from '@eg/share/combobox/combobox.component';
 import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {MarcEditContext} from './editor-context';
+import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap';
+import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
 
-interface MarcSavedEvent {
+export interface MarcSavedEvent {
     marcXml: string;
     bibSource?: number;
+    recordId?: number;
 }
 
 /**
@@ -28,18 +33,43 @@ interface MarcSavedEvent {
 
 export class MarcEditorComponent implements OnInit {
 
-    record: MarcRecord;
     editorTab: 'rich' | 'flat';
     sources: ComboboxEntry[];
+    context: MarcEditContext;
 
+    // True if the save request is in flight
+    dataSaving: boolean;
+
+    @Input() recordType: 'biblio' | 'authority' = 'biblio';
+
+    _pendingRecordId: number;
     @Input() set recordId(id: number) {
-        if (!id) { return; }
         if (this.record && this.record.id === id) { return; }
-        this.fromId(id);
+
+        // Avoid fetching the record by ID before OnInit since we may
+        // not yet know our recordType.
+        if (this.initCalled) {
+            this._pendingRecordId = null;
+            this.fromId(id);
+
+         } else {
+            // fetch later in OnInit
+            this._pendingRecordId = id;
+         }
+    }
+
+    get recordId(): number {
+        return this.record ? this.record.id : this._pendingRecordId;
     }
 
     @Input() set recordXml(xml: string) {
-        if (xml) { this.fromXml(xml); }
+        if (xml) {
+            this.fromXml(xml);
+        }
+    }
+
+    get record(): MarcRecord {
+        return this.context.record;
     }
 
     // Tell us which record source to select by default.
@@ -56,12 +86,17 @@ export class MarcEditorComponent implements OnInit {
     // the record is successfully saved.
     @Output() recordSaved: EventEmitter<MarcSavedEvent>;
 
-    @ViewChild('sourceSelector', { static: true }) sourceSelector: ComboboxComponent;
-    @ViewChild('confirmDelete', { static: true }) confirmDelete: ConfirmDialogComponent;
-    @ViewChild('confirmUndelete', { static: true }) confirmUndelete: ConfirmDialogComponent;
-    @ViewChild('cannotDelete', { static: true }) cannotDelete: ConfirmDialogComponent;
-    @ViewChild('successMsg', { static: true }) successMsg: StringComponent;
-    @ViewChild('failMsg', { static: true }) failMsg: StringComponent;
+    @ViewChild('sourceSelector', {static: false}) sourceSelector: ComboboxComponent;
+    @ViewChild('confirmDelete', {static: false}) confirmDelete: ConfirmDialogComponent;
+    @ViewChild('confirmUndelete', {static: false}) confirmUndelete: ConfirmDialogComponent;
+    @ViewChild('cannotDelete', {static: false}) cannotDelete: ConfirmDialogComponent;
+    @ViewChild('successMsg', {static: false}) successMsg: StringComponent;
+    @ViewChild('failMsg', {static: false}) failMsg: StringComponent;
+
+    fastItemLabel: string;
+    fastItemBarcode: string;
+    showFastAdd: boolean;
+    initCalled = false;
 
     constructor(
         private evt: EventService,
@@ -70,15 +105,31 @@ export class MarcEditorComponent implements OnInit {
         private auth: AuthService,
         private org: OrgService,
         private pcrud: PcrudService,
-        private toast: ToastService
+        private toast: ToastService,
+        private holdings: HoldingsService,
+        private store: ServerStoreService
     ) {
         this.sources = [];
         this.recordSaved = new EventEmitter<MarcSavedEvent>();
+        this.context = new MarcEditContext();
+
+        this.recordSaved.subscribe(_ => this.dataSaving = false);
     }
 
     ngOnInit() {
-        // Default to flat for now since it's all that's supported.
-        this.editorTab = 'flat';
+
+        this.initCalled = true;
+
+        this.context.recordType = this.recordType;
+
+        this.store.getItem('cat.marcedit.flateditor').then(
+            useFlat => this.editorTab = useFlat ? 'flat' : 'rich');
+
+        if (!this.record && this.recordId) {
+            this.fromId(this.recordId);
+        }
+
+        if (this.recordType !== 'biblio') { return; }
 
         this.pcrud.retrieveAll('cbs').subscribe(
             src => this.sources.push({id: +src.id(), label: src.source()}),
@@ -95,62 +146,142 @@ export class MarcEditorComponent implements OnInit {
         );
     }
 
+    changesPending(): boolean {
+        return this.context.changesPending;
+    }
+
+    clearPendingChanges() {
+        this.context.changesPending = false;
+    }
+
+    // Remember the last used tab as the preferred tab.
+    tabChange(evt: NgbTabChangeEvent) {
+
+        // Avoid undo persistence across tabs since that could result
+        // in changes getting lost.
+        this.context.resetUndos();
+
+        if (evt.nextId === 'flat') {
+            this.store.setItem('cat.marcedit.flateditor', true);
+        } else {
+            this.store.removeItem('cat.marcedit.flateditor');
+        }
+    }
+
     saveRecord(): Promise<any> {
         const xml = this.record.toXml();
+        this.dataSaving = true;
+
+        // Save actions clears any pending changes.
+        this.context.changesPending = false;
+        this.context.resetUndos();
 
         let sourceName: string = null;
         let sourceId: number = null;
 
-        if (this.sourceSelector.selected) {
+        if (this.sourceSelector && this.sourceSelector.selected) {
             sourceName = this.sourceSelector.selected.label;
             sourceId = this.sourceSelector.selected.id;
         }
 
+        const emission = {
+            marcXml: xml, bibSource: sourceId, recordId: this.recordId};
+
         if (this.inPlaceMode) {
             // Let the caller have the modified XML and move on.
-            this.recordSaved.emit({marcXml: xml, bibSource: sourceId});
+            this.recordSaved.emit(emission);
             return Promise.resolve();
         }
 
+        let promise;
+
         if (this.record.id) { // Editing an existing record
 
-            const method = 'open-ils.cat.biblio.record.marc.replace';
+            promise = this.modifyRecord(xml, sourceName, sourceId);
 
-            return this.net.request('open-ils.cat', method,
-                this.auth.token(), this.record.id, xml, sourceName
-            ).toPromise().then(response => {
+        } else {
 
-                const evt = this.evt.parse(response);
-                if (evt) {
-                    console.error(evt);
-                    this.failMsg.current().then(msg => this.toast.warning(msg));
-                    return;
-                }
+            promise = this.createRecord(xml, sourceName);
+        }
 
-                this.successMsg.current().then(msg => this.toast.success(msg));
-                this.recordSaved.emit({marcXml: xml, bibSource: sourceId});
-                return response;
-            });
+        // NOTE we do not reinitialize our record with the MARC returned
+        // from the server after a create/update, which means our record
+        // may be out of sync, e.g. missing 901* values.  It's the
+        // callers responsibility to tear us down and rebuild us.
+        return promise.then(marcXml => {
+            if (!marcXml) { return null; }
+            this.successMsg.current().then(msg => this.toast.success(msg));
+            emission.marcXml = marcXml;
+            emission.recordId = this.recordId;
+            this.recordSaved.emit(emission);
+            this.fastAdd();
+            return marcXml;
+        });
+    }
 
-        } else {
-            // TODO: create a new record
-        }
+    modifyRecord(marcXml: string, sourceName: string, sourceId: number): Promise<any> {
+        const method = this.recordType === 'biblio' ?
+            'open-ils.cat.biblio.record.xml.update' :
+            'open-ils.cat.authority.record.overlay';
+
+        return this.net.request('open-ils.cat', method,
+            this.auth.token(), this.record.id, marcXml, sourceName
+
+        ).toPromise().then(response => {
+
+            const evt = this.evt.parse(response);
+            if (evt) {
+                console.error(evt);
+                this.failMsg.current().then(msg => this.toast.warning(msg));
+                this.dataSaving = false;
+                return null;
+            }
+
+            // authority.record.overlay resturns a '1' on success.
+            return typeof response === 'object' ? response.marc() : marcXml;
+        });
+    }
+
+    createRecord(marcXml: string, sourceName?: string): Promise<any> {
+
+        const method = this.recordType === 'biblio' ?
+            'open-ils.cat.biblio.record.xml.create' :
+            'open-ils.cat.authority.record.import';
+
+        return this.net.request('open-ils.cat', method,
+            this.auth.token(), marcXml, sourceName
+        ).toPromise().then(response => {
+
+            const evt = this.evt.parse(response);
+
+            if (evt) {
+                console.error(evt);
+                this.failMsg.current().then(msg => this.toast.warning(msg));
+                this.dataSaving = false;
+                return null;
+            }
+
+            this.record.id = response.id();
+            return response.marc();
+        });
     }
 
     fromId(id: number): Promise<any> {
-        return this.pcrud.retrieve('bre', id)
-        .toPromise().then(bib => {
-            this.record = new MarcRecord(bib.marc());
+        const idlClass = this.recordType === 'authority' ? 'are' : 'bre';
+
+        return this.pcrud.retrieve(idlClass, id)
+        .toPromise().then(rec => {
+            this.context.record = new MarcRecord(rec.marc());
             this.record.id = id;
-            this.record.deleted = bib.deleted() === 't';
-            if (bib.source()) {
-                this.sourceSelector.applyEntryId(+bib.source());
+            this.record.deleted = rec.deleted() === 't';
+            if (idlClass === 'bre' && rec.source()) {
+                this.sourceSelector.applyEntryId(+rec.source());
             }
         });
     }
 
     fromXml(xml: string) {
-        this.record = new MarcRecord(xml);
+        this.context.record = new MarcRecord(xml);
         this.record.id = null;
     }
 
@@ -177,7 +308,7 @@ export class MarcEditorComponent implements OnInit {
                 }
                 return this.fromId(this.record.id)
                 .then(_ => this.recordSaved.emit(
-                    {marcXml: this.record.toXml()}));
+                    {marcXml: this.record.toXml(), recordId: this.recordId}));
             });
         });
     }
@@ -199,9 +330,24 @@ export class MarcEditorComponent implements OnInit {
 
                 return this.fromId(this.record.id)
                 .then(_ => this.recordSaved.emit(
-                    {marcXml: this.record.toXml()}));
+                    {marcXml: this.record.toXml(), recordId: this.recordId}));
             });
         });
     }
+
+    // Spawns the copy editor with the requested barcode and
+    // call number label.  Called after our record is saved.
+    fastAdd() {
+        if (this.showFastAdd && this.fastItemLabel && this.fastItemBarcode) {
+
+            const fastItem = {
+                label: this.fastItemLabel,
+                barcode: this.fastItemBarcode,
+                fast_add: true
+            };
+
+            this.holdings.spawnAddHoldingsUi(this.recordId, null, [fastItem]);
+        }
+    }
 }
 
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.css b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.css
new file mode 100644 (file)
index 0000000..88b31f2
--- /dev/null
@@ -0,0 +1,20 @@
+
+:host >>> .popover {
+  font-family: 'Lucida Console', Monaco, monospace;
+  max-width: 550px;
+}
+
+:host >>> .popover-body {
+  max-height: 400px;
+  overflow-y: auto;
+  overflow-x: auto;
+}
+
+:host >>> .popover-body .menu-entry {
+  white-space: nowrap;
+}
+
+:host >>> .popover-body .menu-entry:hover {
+  background-color: #f8f9fa; /* bootstrap color */
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.html
new file mode 100644 (file)
index 0000000..fa11fef
--- /dev/null
@@ -0,0 +1,17 @@
+
+<ng-container *ngIf="fieldMeta">
+
+  <div class="d-flex">
+    <div class="flex-4">
+      <span id="label-{{randId}}" class="text-left font-weight-bold">
+        {{fieldLabel}}
+      </span>
+    </div>
+      <div class="flex-5">
+        <eg-marc-editable-content
+          [context]="context" [ariaLabel]="fieldLabel"
+          [fixedFieldCode]="fieldCode" fieldType="ffld" moreClasses="p-1">
+        </eg-marc-editable-content>
+      </div>
+  </div>
+</ng-container>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-field.component.ts
new file mode 100644 (file)
index 0000000..6905ec6
--- /dev/null
@@ -0,0 +1,45 @@
+import {Component, Input, Output, OnInit, EventEmitter} from '@angular/core';
+import {IdlObject} from '@eg/core/idl.service';
+import {MarcRecord} from './marcrecord';
+import {MarcEditContext} from './editor-context';
+import {TagTableService} from './tagtable.service';
+
+/**
+ * MARC Fixed Field Editing Component
+ */
+
+@Component({
+  selector: 'eg-fixed-field',
+  templateUrl: './fixed-field.component.html',
+  styleUrls: ['fixed-field.component.css']
+})
+
+export class FixedFieldComponent implements OnInit {
+
+    @Input() fieldCode: string;
+    @Input() fieldLabel: string;
+    @Input() context: MarcEditContext;
+
+    get record(): MarcRecord { return this.context.record; }
+
+    fieldMeta: IdlObject;
+    randId = Math.floor(Math.random() * 10000000);
+
+    constructor() {}
+
+    ngOnInit() {
+        this.init().then(_ =>
+            this.context.recordChange.subscribe(__ => this.init()));
+    }
+
+    init(): Promise<any> {
+        if (!this.record) { return Promise.resolve(); }
+
+        // If no field metadata is found for this fixed field code and
+        // record type combo, the field will be hidden in the UI.
+        return this.context.tagTable.getFfFieldMeta(this.fieldCode)
+        .then(fieldMeta => this.fieldMeta = fieldMeta);
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.html
new file mode 100644 (file)
index 0000000..97d866f
--- /dev/null
@@ -0,0 +1,281 @@
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Type" fieldLabel="Type"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="ELvl" fieldLabel="ELvl"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Source" fieldLabel="Source"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Audn" fieldLabel="Audn"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Ctrl" fieldLabel="Ctrl"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Lang" fieldLabel="Lang"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="BLvl" fieldLabel="BLvl"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Form" fieldLabel="Form"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Conf" fieldLabel="Conf"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Biog" fieldLabel="Biog"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="MRec" fieldLabel="MRec"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Ctry" fieldLabel="Ctry"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="s_l" fieldLabel="s_l"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Cont" fieldLabel="Cont"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="GPub" fieldLabel="GPub"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="LitF" fieldLabel="LitF"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Indx" fieldLabel="Indx"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Alph" fieldLabel="Alph"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Desc" fieldLabel="Desc"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Ills" fieldLabel="Ills"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Fest" fieldLabel="Fest"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="DtSt" fieldLabel="DtSt"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Date1" fieldLabel="Date1"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Date2" fieldLabel="Date2"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="SrTp" fieldLabel="SrTp"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Regl" fieldLabel="Regl"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Orig" fieldLabel="Orig"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Freq" fieldLabel="Freq"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="EntW" fieldLabel="EntW"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="TrAr" fieldLabel="TrAr"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Part" fieldLabel="Part"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="LTxt" fieldLabel="LTxt"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="FMus" fieldLabel="FMus"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="AccM" fieldLabel="AccM"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Comp" fieldLabel="Comp"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="SpFm" fieldLabel="SpFm"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Relf" fieldLabel="Relf"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Proj" fieldLabel="Proj"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="CrTp" fieldLabel="CrTp"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="TMat" fieldLabel="TMat"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Time" fieldLabel="Time"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Tech" fieldLabel="Tech"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="File" fieldLabel="File"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Type_tbmfhd" fieldLabel="Type_tbmfhd"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="ELvl_tbmfhd" fieldLabel="ELvl_tbmfhd"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Item_tbmfhd" fieldLabel="Item_tbmfhd"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="GeoDiv" fieldLabel="GeoDiv"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Roman" fieldLabel="Roman"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="CatLang" fieldLabel="CatLang"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Kind" fieldLabel="Kind"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Rules" fieldLabel="Rules"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Subj" fieldLabel="Subj"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Series" fieldLabel="Series"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="SerNum" fieldLabel="SerNum"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="NameUse" fieldLabel="NameUse"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="SubjUse" fieldLabel="SubjUse"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="SerUse" fieldLabel="SerUse"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="TypeSubd" fieldLabel="TypeSubd"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="GovtAgn" fieldLabel="GovtAgn"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="RefStatus" fieldLabel="RefStatus"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="UpdStatus" fieldLabel="UpdStatus"></eg-fixed-field>
+  </div>
+</div>
+<div class="row p-0">
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Name" fieldLabel="Name"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="Status" fieldLabel="Status"></eg-fixed-field>
+  </div>
+  <div class="col-lg-2">
+    <eg-fixed-field i18n-fieldLabel [context]="context" 
+      fieldCode="ModRec" fieldLabel="ModRec"></eg-fixed-field>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/fixed-fields-editor.component.ts
new file mode 100644 (file)
index 0000000..d029816
--- /dev/null
@@ -0,0 +1,31 @@
+import {Component, Input, Output, OnInit, AfterViewInit, EventEmitter,
+    OnDestroy} from '@angular/core';
+import {IdlService} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {MarcRecord} from './marcrecord';
+import {MarcEditContext} from './editor-context';
+import {TagTableService} from './tagtable.service';
+
+/**
+ * MARC Fixed Fields Editor Component
+ */
+
+@Component({
+  selector: 'eg-fixed-fields-editor',
+  templateUrl: './fixed-fields-editor.component.html'
+})
+
+export class FixedFieldsEditorComponent implements OnInit {
+
+    @Input() context: MarcEditContext;
+    get record(): MarcRecord { return this.context.record; }
+
+    constructor(
+        private idl: IdlService,
+        private org: OrgService,
+        private tagTable: TagTableService
+    ) {}
+
+    ngOnInit() {}
+}
+
index eaf54a9..0ef573f 100644 (file)
@@ -1,6 +1,7 @@
 
 <div *ngIf="record">
   <textarea class="form-control flat-editor-content" 
+    (change)="textChanged()"
     (blur)="record.absorbBreakerChanges()"
     [(ngModel)]="record.breakerText" rows="{{rowCount()}}" spellcheck="false">
   </textarea>
index b5e2f41..86a64ac 100644 (file)
@@ -1,9 +1,9 @@
-import {Component, Input, OnInit, Host} from '@angular/core';
+import {Component, Input, OnInit} from '@angular/core';
 import {IdlService} from '@eg/core/idl.service';
 import {OrgService} from '@eg/core/org.service';
 import {ServerStoreService} from '@eg/core/server-store.service';
-import {MarcEditorComponent} from './editor.component';
 import {MarcRecord} from './marcrecord';
+import {MarcEditContext} from './editor-context';
 
 /**
  * MARC Record flat text (marc-breaker) editor.
@@ -17,19 +17,22 @@ import {MarcRecord} from './marcrecord';
 
 export class MarcFlatEditorComponent implements OnInit {
 
+    @Input() context: MarcEditContext;
     get record(): MarcRecord {
-        return this.editor.record;
+        return this.context.record;
     }
 
     constructor(
         private idl: IdlService,
         private org: OrgService,
-        private store: ServerStoreService,
-        @Host() private editor: MarcEditorComponent
-    ) {
-    }
+        private store: ServerStoreService
+    ) {}
 
-    ngOnInit() {}
+    ngOnInit() {
+        // Be sure changes made in the enriched editor are
+        // reflected here.
+        this.record.breakerText = this.record.toBreaker();
+    }
 
     // When we have breaker text, limit the vertical expansion of the
     // text area to the size of the data plus a little padding.
@@ -39,6 +42,10 @@ export class MarcFlatEditorComponent implements OnInit {
         }
         return 40;
     }
+
+    textChanged() {
+        this.context.changesPending = true;
+    }
 }
 
 
index a18eb0b..4f2a39f 100644 (file)
@@ -1,22 +1,40 @@
 import {NgModule} from '@angular/core';
 import {StaffCommonModule} from '@eg/staff/common.module';
+import {CommonWidgetsModule} from '@eg/share/common-widgets.module';
 import {MarcEditorComponent} from './editor.component';
 import {MarcRichEditorComponent} from './rich-editor.component';
 import {MarcFlatEditorComponent} from './flat-editor.component';
+import {FixedFieldsEditorComponent} from './fixed-fields-editor.component';
+import {FixedFieldComponent} from './fixed-field.component';
+import {TagTableService} from './tagtable.service';
+import {EditableContentComponent} from './editable-content.component';
+import {AuthorityLinkingDialogComponent} from './authority-linking-dialog.component';
+import {MarcEditorDialogComponent} from './editor-dialog.component';
+import {PhysCharDialogComponent} from './phys-char-dialog.component';
+import {HoldingsModule} from '@eg/staff/share/holdings/holdings.module';
 
 @NgModule({
     declarations: [
         MarcEditorComponent,
         MarcRichEditorComponent,
-        MarcFlatEditorComponent
+        MarcFlatEditorComponent,
+        FixedFieldsEditorComponent,
+        FixedFieldComponent,
+        EditableContentComponent,
+        MarcEditorDialogComponent,
+        PhysCharDialogComponent,
+        AuthorityLinkingDialogComponent
     ],
     imports: [
-        StaffCommonModule
+        StaffCommonModule,
+        CommonWidgetsModule,
+        HoldingsModule
     ],
     exports: [
         MarcEditorComponent
     ],
     providers: [
+        TagTableService
     ]
 })