[GIT] Evergreen ILS branch main updated. ff6062f0f891788b8183579ab02040cfb5f8b301

This is an automated email from the git hooks/post-receive script. It was generated because a ref change was pushed to the repository containing the project "Evergreen ILS". The branch, main has been updated via ff6062f0f891788b8183579ab02040cfb5f8b301 (commit) via b3d8be1301a6ca113087eef4899d2bec991b1f05 (commit) via a8ae0b8a15ffc2f22b48d75b2f4c1ad0af498890 (commit) via dcb791d12d2fe58d2b8450944699883d5a8c925d (commit) via ee21f8030ae2042db7543ffb3e1c526a775fd29e (commit) via 3ece778cdf5e325fb0b9bde3789d3661f5f9f4b4 (commit) via 51d8f42543838ae583ee1e21daa44d7a41cdf4ff (commit) via d9ae26b82e8573474cc2e2f167e09c8cb45e06b1 (commit) via bc62c865946fe7e84d36cd98ca35d674deb06c8a (commit) via 61db07fc1d5f08396a442e6be9e4f5a08bc41ef3 (commit) via 500ca5f441c04b91e791eeccc6e16d3a1836ab54 (commit) via a9ff9be260c2f428ced83088345007dc0b000ad9 (commit) via 85989ff210441c0dff47ccf572586e681e0f44ce (commit) via 8cd780cb7c0ec55705ddc7e099e77143de9c4d52 (commit) from 6a3a51bb937badfb0fd8a3ac9d01a4f3bd11d23f (commit) Those revisions listed above that are new to this repository have not appeared on any other notification email; so we list those revisions in full, below. - Log ----------------------------------------------------------------- commit ff6062f0f891788b8183579ab02040cfb5f8b301 Author: Terran McCanna <tmccanna@georgialibraries.org> Date: Wed Mar 19 17:11:36 2025 -0400 LP2074112 Stamp upgrade script Signed-off-by: Terran McCanna <tmccanna@georgialibraries.org> diff --git a/Open-ILS/src/sql/Pg/002.schema.config.sql b/Open-ILS/src/sql/Pg/002.schema.config.sql index 0f7b681dab..e450796117 100644 --- a/Open-ILS/src/sql/Pg/002.schema.config.sql +++ b/Open-ILS/src/sql/Pg/002.schema.config.sql @@ -92,7 +92,7 @@ CREATE TRIGGER no_overlapping_deps BEFORE INSERT OR UPDATE ON config.db_patch_dependencies FOR EACH ROW EXECUTE PROCEDURE evergreen.array_overlap_check ('deprecates'); -INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1462', :eg_version); -- jeffdavis/gmcharlt +INSERT INTO config.upgrade_log (version, applied_to) VALUES ('1463', :eg_version); -- sleary/jetheridge/rdavis/tmccanna CREATE TABLE config.bib_source ( id SERIAL PRIMARY KEY, diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.org-setting-template-bar.sql b/Open-ILS/src/sql/Pg/upgrade/1463.data.org-setting-template-bar.sql similarity index 96% rename from Open-ILS/src/sql/Pg/upgrade/XXXX.data.org-setting-template-bar.sql rename to Open-ILS/src/sql/Pg/upgrade/1463.data.org-setting-template-bar.sql index 2fb48a6492..d79accff3e 100644 --- a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.org-setting-template-bar.sql +++ b/Open-ILS/src/sql/Pg/upgrade/1463.data.org-setting-template-bar.sql @@ -1,6 +1,6 @@ BEGIN; -SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version); +SELECT evergreen.upgrade_deps_block_check('1463', :eg_version); INSERT INTO config.org_unit_setting_type (grp, name, datatype, label, description) commit b3d8be1301a6ca113087eef4899d2bec991b1f05 Author: Jason Etheridge <jason@equinoxOLI.org> Date: Wed Mar 12 18:22:55 2025 +0000 LP2074112 Item Alerts, Notes, Tags, and Templates Rework Ports the holdings templates admin interface to Angular. Adds item alerts, notes, and tags to holdings templates; allows editing alerts and notes in templates, and provides link to tag administration to screen for editing tags. Revises alert, note, and tag dialogs to reflect updated data as changes are made. Includes a redesigned item attribute screen with a more compact layout and many accessibility improvements to headings, list structure, and form labels. Addresses the following bugs: -- Bug 1989790: Add item alerts and notes to holdings templates -- Bug 1957115: Port holdings templates admin interface to Angular -- Bug 1990291: Clear button for templates missing -- Bug 2044026: Adding or removing item notes modal can be confusing with repeated use -- Bug 2044028: Adding or removing item alerts modal can be confusing with repeated use -- Bug 2002436: Inactive item alert types appear -- Bug 1804065: Item tags cannot be included in item template -- Bug 2056606: Item alert types cannot be edited in holdings templates -- Bug 1954477: Holdings editor times out with no warning -- Bug 2020589: Unable to see or edit some item alerts, notes, and tags Co-authored-by: Stephanie Leary <stephanie.leary@equinoxoli.org> Signed-off-by: Stephanie Leary <stephanie.leary@equinoxoli.org> Signed-off-by: Jason Etheridge <jason@equinoxOLI.org> Signed-off-by: Ruth Davis <rdavis@evergreencdi.org> Signed-off-by: Terran McCanna <tmccanna@georgialibraries.org> diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/admin-local-splash.component.html b/Open-ILS/src/eg2/src/app/staff/admin/local/admin-local-splash.component.html index 33659d1d07..9901673b0e 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/local/admin-local-splash.component.html +++ b/Open-ILS/src/eg2/src/app/staff/admin/local/admin-local-splash.component.html @@ -36,8 +36,10 @@ routerLink="/staff/admin/local/permission/grp_penalty_threshold"></eg-link-table-link> <eg-link-table-link i18n-label label="Hold Policies" routerLink="/staff/admin/local/config/hold_matrix_matchpoint"></eg-link-table-link> - <eg-link-table-link i18n-label label="Holdings Template Editor" + <eg-link-table-link i18n-label label="Holdings Template Editor (Legacy)" url="/eg/staff/cat/volcopy/edit_templates"></eg-link-table-link> + <eg-link-table-link i18n-label label="Holdings Template Editor" + routerLink="/staff/cat/volcopy/template_grid"></eg-link-table-link> <eg-link-table-link i18n-label label="Hopeless Holds" routerLink="/staff/hopeless"></eg-link-table-link> <eg-link-table-link i18n-label label="Item Alert Suppression" diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/circ_matrix_matchpoint/circ-matrix-matchpoint.component.html b/Open-ILS/src/eg2/src/app/staff/admin/local/circ_matrix_matchpoint/circ-matrix-matchpoint.component.html index 76299e9fdb..db2b939060 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/local/circ_matrix_matchpoint/circ-matrix-matchpoint.component.html +++ b/Open-ILS/src/eg2/src/app/staff/admin/local/circ_matrix_matchpoint/circ-matrix-matchpoint.component.html @@ -17,7 +17,7 @@ </eg-org-family-select> </ng-container> </div> - <div class="col-lg-6 d-flex"> + <div class="col-lg-6 hstack"> <div class="flex-1"></div><!-- push right --> <ng-container *ngIf="gridFilters"> <span i18n>Filters Applied: {{gridFilters | json}}</span> diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/staff-portal-page.component.html b/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/staff-portal-page.component.html index 1e0403599e..f07bf041b3 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/staff-portal-page.component.html +++ b/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/staff-portal-page.component.html @@ -43,7 +43,7 @@ </eg-org-family-select> </ng-container> </div> - <div class="col-lg-6 d-flex"> + <div class="col-lg-6 hstack"> <div class="flex-1"></div><!-- push right --> <ng-container *ngIf="gridFilters"> <span i18n>Filters Applied: {{gridFilters | json}}</span> diff --git a/Open-ILS/src/eg2/src/app/staff/cat/item/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/cat/item/routing.module.ts index 9ce2e42951..9a8e3b7e69 100644 --- a/Open-ILS/src/eg2/src/app/staff/cat/item/routing.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/cat/item/routing.module.ts @@ -1,6 +1,7 @@ import {NgModule} from '@angular/core'; import {RouterModule, Routes} from '@angular/router'; import {MarkItemMissingPiecesComponent} from './missing-pieces.component'; +import {CopyAlertsPageComponent} from '@eg/staff/share/holdings/copy-alerts-page.component'; const routes: Routes = [{ path: 'missing_pieces', @@ -8,6 +9,9 @@ const routes: Routes = [{ }, { path: 'missing_pieces/:id', component: MarkItemMissingPiecesComponent +}, { + path: 'alerts', + component: CopyAlertsPageComponent }]; @NgModule({ diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/config.component.html b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/config.component.html index e6ab1c409a..f328fee710 100644 --- a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/config.component.html +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/config.component.html @@ -9,7 +9,9 @@ <div class="row"> <div class="col-lg-12"> <div class="card"> - <div class="card-header" i18n>Holdings Display Preferences</div> + <div class="card-header"> + <h3 i18n>Holdings Display Preferences</h3> + </div> <ul class="list-group list-group-flush"> <li class="list-group-item"> <div class="form-check form-check-inline"> @@ -90,7 +92,7 @@ id="volcopy-unified-interface" [(ngModel)]="volcopy.defaults.values.unified_display"> <label class="form-label form-check-label" for="volcopy-unified-interface" i18n> - Unified Holdings, Item Attributes Display, and Item Templates + Unified Holdings and Item Attributes Display </label> </div> </li> @@ -103,7 +105,9 @@ <div class="row"> <div class="col-lg-12"> <div class="card"> - <div class="card-header" i18n>Holdings Creation Defaults</div> + <div class="card-header"> + <h3 i18n>Holdings Creation Defaults</h3> + </div> <ul class="list-group list-group-flush p-2"> <li class="list-group-item"> <div class="row"> @@ -178,7 +182,9 @@ <div class="row"> <div class="col-lg-6"> <div class="card"> - <div class="card-header" i18n>Item Attributes Behavior</div> + <div class="card-header"> + <h3 i18n>Item Attributes Behavior</h3> + </div> <ul class="list-group list-group-flush"> <li class="list-group-item"> <div class="form-check form-check-inline"> @@ -237,7 +243,9 @@ <!-- COLUMN 1 --> <div class="flex-1 p-1"> <div class="card"> - <div class="card-header" i18n>Identification</div> + <div class="card-header"> + <h3 i18n>Identification</h3> + </div> <ul class="list-group list-group-flush"> <li class="list-group-item"> <div class="form-check form-check-inline"> @@ -316,7 +324,9 @@ <!-- COLUMN 2 --> <div class="flex-1 p-1"> <div class="card"> - <div class="card-header" i18n>Location</div> + <div class="card-header"> + <h3 i18n>Location</h3> + </div> <ul class="list-group list-group-flush"> <li class="list-group-item"> <div class="form-check form-check-inline"> @@ -366,7 +376,9 @@ <div class="flex-1 p-1"> <div class="card"> - <div class="card-header" i18n>Circulation</div> + <div class="card-header"> + <h3 i18n>Circulation</h3> + </div> <ul class="list-group list-group-flush"> <li class="list-group-item"> <div class="form-check form-check-inline"> @@ -448,6 +460,26 @@ </label> </div> </li> + <li class="list-group-item"> + <div class="form-check form-check-inline"> + <input class="form-check-input" type="checkbox" + id="show-opac_visible-attr" + [(ngModel)]="volcopy.defaults.hidden.opac_visible"> + <label class="form-label form-check-label" for="show-opac_visible-attr" i18n> + OPAC Visible + </label> + </div> + </li> + <li class="list-group-item"> + <div class="form-check form-check-inline"> + <input class="form-check-input" type="checkbox" + id="show-ref-attr" + [(ngModel)]="volcopy.defaults.hidden.ref"> + <label class="form-label form-check-label" for="show-ref-attr" i18n> + Reference + </label> + </div> + </li> </ul> </div> </div> @@ -456,18 +488,10 @@ <div class="flex-1 p-1"> <div class="card"> - <div class="card-header" i18n>Miscellaneous</div> + <div class="card-header"> + <h3 i18n>Cost and Condition</h3> + </div> <ul class="list-group list-group-flush"> - <li class="list-group-item"> - <div class="form-check form-check-inline"> - <input class="form-check-input" type="checkbox" - id="show-copy_alerts-attr" - [(ngModel)]="volcopy.defaults.hidden.copy_alerts"> - <label class="form-label form-check-label" for="show-copy_alerts-attr" i18n> - Item Alerts - </label> - </div> - </li> <li class="list-group-item"> <div class="form-check form-check-inline"> <input class="form-check-input" type="checkbox" @@ -498,26 +522,6 @@ </label> </div> </li> - <li class="list-group-item"> - <div class="form-check form-check-inline"> - <input class="form-check-input" type="checkbox" - id="show-opac_visible-attr" - [(ngModel)]="volcopy.defaults.hidden.opac_visible"> - <label class="form-label form-check-label" for="show-opac_visible-attr" i18n> - OPAC Visible - </label> - </div> - </li> - <li class="list-group-item"> - <div class="form-check form-check-inline"> - <input class="form-check-input" type="checkbox" - id="show-ref-attr" - [(ngModel)]="volcopy.defaults.hidden.ref"> - <label class="form-label form-check-label" for="show-ref-attr" i18n> - Reference - </label> - </div> - </li> <li class="list-group-item"> <div class="form-check form-check-inline"> <input class="form-check-input" type="checkbox" @@ -546,15 +550,27 @@ <div class="flex-1 p-1"> <div class="card"> - <div class="card-header" i18n>Statistics</div> + <div class="card-header"> + <h3 i18n>Alerts, Notes, Tags, Statistics</h3> + </div> <ul class="list-group list-group-flush"> + <li class="list-group-item"> + <div class="form-check form-check-inline"> + <input class="form-check-input" type="checkbox" + id="show-copy_alerts-attr" + [(ngModel)]="volcopy.defaults.hidden.copy_alerts"> + <label class="form-label form-check-label" for="show-copy_alerts-attr" i18n> + Item Alerts + </label> + </div> + </li> <li class="list-group-item"> <div class="form-check form-check-inline"> <input class="form-check-input" type="checkbox" id="show-copy_tags-attr" [(ngModel)]="volcopy.defaults.hidden.copy_tags"> <label class="form-label form-check-label" for="show-copy_tags-attr" i18n> - Add Item Tags + Item Tags </label> </div> </li> @@ -564,7 +580,7 @@ id="show-copy_notes-attr" [(ngModel)]="volcopy.defaults.hidden.copy_notes"> <label class="form-label form-check-label" for="show-copy_notes-attr" i18n> - Add Item Notes + Item Notes </label> </div> </li> diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.css b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.css new file mode 100644 index 0000000000..d2f2d5220c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.css @@ -0,0 +1,230 @@ +/* See also staff/share/batch-item-attr.component.css */ + +#volcopy-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + column-gap: 0.75rem; +} + +#volcopy-grid h4 { + font-weight: normal; + padding: 0.25rem; +} + +.card { + margin-block: 0.5rem; +} + +.batch-header { + background-color: var(--batch-item-attr-header-bg); +} + +.template-row { + background-color: #007a54; +} + +.card-header { + display: flex; +} + +.card-header h5 { + margin: 0; +} + +.card-header .readonly, +.card-header .edit-toggle, +.card-header h5:not(.readonly):not(:has(.edit-toggle)) { + background-color: transparent; + border: 0; + font-weight: bold; + padding-block: 0.25rem; + padding-inline: 0 0.5rem; + text-align: start; + text-decoration: none; +} + +.card-header .edit-toggle:is(:hover, :focus, :focus-visible) { + background-color: transparent; + text-decoration: underline; +} + +.card .dl-grid { + grid-template-columns: 1fr max-content; + column-gap: 1rem; +} + +.card-body fieldset { + margin-bottom: 0.5rem; +} + +/* OPTIONAL COMPACT VIEW */ +#volcopy-grid { + container-name: volcopy; + container-type: inline-size; +} + +#volcopy-grid, +#volcopy-grid #col-text-fields { + align-items: flex-start; + display: flex; + flex-grow: 1; + flex-wrap: wrap; + gap: 2rem; +} + +#volcopy-grid #col-text-fields-header { + display: none; +} + +#volcopy-grid .col { + flex: 1 0 calc(300px - 2rem); +} + +#volcopy-grid .col h4, +#volcopy-grid #col-text-fields-header { + border-block-end: 1px solid var(--border); + font-size: 1.05rem; + font-weight: 600; + margin-block-end: 0.25rem; + padding: 0.25rem; +} + +::ng-deep #volcopy-grid li .card { + background: var(--bs-card-bg); + border-radius: 0; + border-width: 0 0 1px; + display: grid; + grid-template-columns: minmax(30%, auto) auto; + justify-content: space-between; + margin: 0; + padding: 0.25rem; +} + +::ng-deep #volcopy-grid li .card:has(.edit-buttons) { + display: block; + padding: 0.5rem 1rem; +} + +::ng-deep #volcopy-grid li .card-header { + grid-column: 1; + background: none; + border: 0; + padding: 0; +} + +::ng-deep #volcopy-grid li .card-body { + grid-column: 2; + background: none; + border: 0; + padding: 0; +} + +::ng-deep #volcopy-grid li .card-body .dl-grid { + column-gap: 0; + text-align: end; +} + +::ng-deep #volcopy-grid li .card-body .dl-grid:has(.def.numeric) { + grid-template-columns: max-content max-content; + column-gap: 0.75rem; +} + +/* Base required styling */ +::ng-deep #volcopy-grid li .card.required .card-header h5:not(:has(.edit-toggle)):after, +::ng-deep #volcopy-grid li .card.required .card-header .edit-toggle:after { + content: "*"; + font-weight: bold; +} + +/* Base has-changes styling */ +::ng-deep #volcopy-grid li .card.has-changes { + --bs-card-cap-color: var(--bs-success-text-emphasis); + border-inline-start: 3px solid var(--bs-success); +} + +::ng-deep #volcopy-grid li .card.has-changes:not(:has(.edit-buttons)) { + --bs-card-bg: var(--bs-success-bg-subtle); + --bs-card-color: var(--bs-success-text-emphasis); + border-block-end: 1px solid var(--bs-success-border-subtle); +} + +/* Required-not-met takes precedence over has-changes */ +::ng-deep #volcopy-grid li .card.required-not-met, +::ng-deep #volcopy-grid li .card.has-changes.required-not-met { + --bs-card-cap-color: var(--bs-danger-text-emphasis); + border-inline-start: 3px dotted var(--bs-danger); +} + +::ng-deep #volcopy-grid li .card.required-not-met:not(:has(.edit-buttons)), +::ng-deep #volcopy-grid li .card.has-changes.required-not-met:not(:has(.edit-buttons)) { + --bs-card-bg: var(--bs-danger-bg-subtle); + --bs-card-color: var(--bs-danger-text-emphasis); + border-block-end: 1px solid var(--bs-danger-border-subtle); +} + +::ng-deep #volcopy-grid li .card.has-changes.required-not-met .term .value:before { + content: "warning"; + font-family: "Material Icons"; + font-weight: normal; + margin-inline-end: .3ch; + vertical-align: bottom; +} + +::ng-deep #volcopy-grid li .card.required:not(.has-changes) .card-header .edit-toggle::after { + color: var(--bs-body-color); +} + +::ng-deep #volcopy-grid li .card:is(.has-changes, .required-not-met) .card-header .field-header, +::ng-deep #volcopy-grid li .card:is(.has-changes, .required-not-met) .card-header .edit-toggle, +::ng-deep #volcopy-grid li .card:is(.has-changes, .required-not-met) .card-header .edit-toggle:is(:hover, :focus, :focus-visible) { + color: var(--bs-card-cap-color); +} + +::ng-deep #volcopy-grid li .card:is(.has-changes, .required-not-met) .card-header .edit-toggle:is(:focus, :focus-visible) { + outline: 0.25rem solid rgba(var(--bs-yellow-400-rgba), 0.5); +} + +@container volcopy (max-width: 1499px) { + ::ng-deep #volcopy-grid li .card:is(.copy_alerts, .copy_notes, .copy_tags, .copy_stats) { + border-block-end: 0; + display: block; + padding: 0.5rem 0.25rem; + } + + #volcopy-grid #col-text-fields .card-header { + display: none; + } + + #volcopy-grid #col-text-fields > .col .list-unstyled { + margin-block-end: 1rem; + } +} + +@container volcopy (min-width: 1500px) { + #volcopy-grid #col-text-fields { + flex: 1 0 calc(300px - 2rem); + gap: 0; + margin-block-end: 1rem; + } + + #volcopy-grid #col-text-fields > .col .list-unstyled { + margin: 0; + } + + #volcopy-grid #col-text-fields-header { + display: block; + width: 100%; + } + + #volcopy-grid #col-text-fields .col h4, + #volcopy-grid #col-text-fields .card-body .btn-outline-dark { + display: none; + } + + ::ng-deep #volcopy-grid li .card.copy_stats { + border-block-end: 0; + display: block; + padding: 0.5rem 0.25rem; + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.html b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.html index 8f8f190a1f..520cca67e3 100644 --- a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.html +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.html @@ -15,88 +15,106 @@ <eg-string #deletedHoldingsTemplate i18n-text text="Deleted holdings template"></eg-string> <!-- We ask this question a lot. Here's a handy template --> -<ng-template #yesNoSelect let-field="field"> - <eg-combobox domId="{{field}}-input" - [required]="true" [ngModel]="values['field']" - (ngModelChange)="values[field] = $event ? $event.id : null"> - <eg-combobox-entry entryId="t" entryLabel="Yes" i18n-entryLabel> - </eg-combobox-entry> - <eg-combobox-entry entryId="f" entryLabel="No" i18n-entryLabel> - </eg-combobox-entry> - </eg-combobox> +<ng-template #yesNoSelect let-field="field" let-value="value" let-required="required" let-options="yesNoOptions"> + <fieldset [ngClass]="{'required': required}" [attr.aria-labelledby]="'label-' + field + '-input'"> + <div *ngFor="let option of yesNoOptions" class="form-check form-check-inline"> + <input + type="radio" + class="form-check-input" + id="{{field}}-input-{{option.value}}" + [attr.name]="field" + [value]="option.value" + [(ngModel)]="values[field]" + [required]="required" + [attr.aria-required]="required" + (keydown.enter)="save(field)" (keydown.escape)="cancel(field)" + /> + <label for="{{field}}-input-{{option.value}}" class="form-check-label">{{ option.label }}</label> + </div> + </fieldset> </ng-template> <!-- this one is also repeated a lot --> <ng-template #batchAttr let-field="field" let-required="required" let-label="label" let-template="template" let-displayAs="displayAs"> - <eg-batch-item-attr - [name]="field" - [label]="label || copyFieldLabel(field)" + <eg-batch-item-attr + [name]="field" + [label]="label || fieldLabel(field)" [readOnly]="!userMayEdit" + [templateOnlyMode]="templateOnlyMode" [valueRequired]="required" [displayAs]="displayAs" [editInputDomId]="field + '-input'" [editTemplate]="template" [labelCounts]="itemAttrCounts(field)" - (valueCleared)="applyCopyValue(field, null)" + (valueCleared)="valueCleared(field)" (changesSaved)="applyCopyValue(field, undefined, $event)"> </eg-batch-item-attr> </ng-template> <!-- Copy Templates --> -<div class="row border rounded border-dark pt-2 pb-2 bg-faint"> - <div class="col-lg-1 fw-bold" i18n>Templates:</div> - <div class="col-lg-4"> - <eg-combobox #copyTemplateCbox domId="template-select" - [allowFreeText]="true" [entries]="volcopy.templateNames"> +<div *ngIf="templateOnlyMode || !hideTemplateBar" class="row px-1 my-3"> + <label for="template-select" class="col-auto col-form-label fw-bold" i18n>Templates:</label> + <div class="col-auto"> + <eg-combobox #copyTemplateCbox domId="template-select" + [allowFreeText]="true" [entries]="volcopy.templateNames" + (onChange)="saveTemplateCboxSelection($event)"> </eg-combobox> </div> - <div class="col-lg-7 d-flex"> - <button type="button" class="btn btn-outline-dark me-2" (click)="applyTemplate()" i18n>Apply Template</button> - <button type="button" class="btn btn-outline-dark me-2" (click)="saveTemplate()" i18n>Save Template</button> - - <!-- - The typical approach of wrapping a file input in a <label> results - in button-ish things that have slightly different dimensions. - Instead have a button activate a hidden file input. - --> - <button type="button" class="btn btn-outline-dark me-2" (click)="templateFile.click()"> - <input type="file" class="d-none" #templateFile - (change)="importTemplate($event)" id="template-file-upload"/> - <span i18n>Import Templates</span> - </button> - <input type="file" class="d-none" #templateFile - (change)="importTemplate($event)" id="template-file-upload"/> - - <a (click)="exportTemplate($event)" - download="export_copy_template.json" [href]="exportTemplateUrl()"> - <button type="button" class="btn btn-outline-dark me-2" i18n>Export All Templates</button> - </a> - - <div class="flex-1"> </div> - <button type="button" class="btn btn-outline-danger me-2" - (click)="deleteTemplate()" i18n>Delete Template</button> + <div class="col-auto flex-fill d-flex align-items-start"> + <button type="button" class="btn btn-outline-primary me-2" (click)="applyTemplate()" i18n>Apply Template</button> + <button *ngIf="(templateOnlyMode || showSaveInEditor) && !copyTemplateCbox.selected?.freetext" + type="button" + class="btn btn-outline-dark me-2" + [disabled]="!copyTemplateCbox.selected" + (click)="saveTemplate(false)">Save Template</button> + <button *ngIf="(templateOnlyMode || showSaveInEditor) && copyTemplateCbox.selected?.freetext" + type="button" + class="btn btn-outline-dark me-2" + (click)="saveTemplate(true)">Save as New Template</button> + + <!-- The grid interface is ostensibly responsible for these actions now --> + <ng-container *ngIf="false"> + <label for="template-file-upload" class="form-label" i18n>Import Templates</label> + <input *ngIf="templateOnlyMode" type="file" #templateFile + (change)="importTemplate($event)" id="template-file-upload"/> + + <input *ngIf="templateOnlyMode" type="file" #templateFile + (change)="importTemplate($event)" id="template-file-upload"/> + + <a *ngIf="templateOnlyMode" (click)="exportTemplate($event)" + download="export_copy_template.json" [href]="exportTemplateUrl()"> + <button type="button" class="btn btn-outline-dark me-2" i18n>Export All Templates</button> + </a> + + <div *ngIf="templateOnlyMode" class="flex-1"> </div> + <button type="button" class="btn btn-destroy me-2" + (click)="deleteTemplate()" i18n>Delete Template</button> + </ng-container> + + <button type="button" class="btn btn-destroy ms-auto" (click)="clearChangesAction()" i18n>Clear Changes</button> </div> </div> -<div class="row d-flex"> +<div id="volcopy-grid"> <!-- COLUMN 1 --> - <div class="flex-1 p-1"> - <div class="p-1"><h4 class="fw-bold" i18n>Identification</h4></div> - + <div class="col"> + <h4 i18n>Identification</h4> - <div class="mb-1" *ngIf="displayAttr('status')"> + <ul class="list-unstyled"> + <li *ngIf="displayAttr('status')"> <ng-container *ngIf="statusEditable(); else noEditStat"> <ng-template #statusTemplate> - <eg-combobox domId="status-input" + <eg-combobox domId="status-input" ariaLabelledby="label-status-input" (ngModelChange)="values['status'] = $event ? $event.id : null" - [ngModel]="values['status']" [disableEntries]="volcopy.magicCopyStats"> - <eg-combobox-entry + [ngModel]="values['status']" (keydown)="onKeydown('status', $event)" + [disableEntries]="volcopy.magicCopyStats"> + <eg-combobox-entry *ngFor="let stat of volcopy.commonData.acp_status" - [entryId]="stat.id()" [entryLabel]="stat.name()"> + [entryId]="stat.id()" [entryLabel]="stat.name()" [selected]="values['status'] === stat.id()"> </eg-combobox-entry> </eg-combobox> </ng-template> @@ -110,162 +128,213 @@ [labelCounts]="itemAttrCounts('status')"> </eg-batch-item-attr> </ng-template> - </div> + </li> - <div class="mb-1" *ngIf="displayAttr('barcode')"> + <li *ngIf="!templateOnlyMode && displayAttr('barcode')"> <eg-batch-item-attr label="Barcode" i18n-label [readOnly]="true" [labelCounts]="itemAttrCounts('barcode')"> </eg-batch-item-attr> - </div> + </li> - <div class="mb-1" *ngIf="displayAttr('create_date')"> + <li *ngIf="!templateOnlyMode && displayAttr('create_date')"> <eg-batch-item-attr label="Creation Date" i18n-label [readOnly]="true" [labelCounts]="itemAttrCounts('create_date')"> </eg-batch-item-attr> - </div> + </li> - <div class="mb-1" *ngIf="displayAttr('active_date')"> + <li *ngIf="!templateOnlyMode && displayAttr('active_date')"> <eg-batch-item-attr label="Active Date" i18n-label [readOnly]="true" [labelCounts]="itemAttrCounts('active_date')"> </eg-batch-item-attr> - </div> + </li> - <div class="mb-1" *ngIf="displayAttr('creator')"> + <li *ngIf="!templateOnlyMode && displayAttr('creator')"> <eg-batch-item-attr label="Creator" i18n-label [readOnly]="true" [labelCounts]="itemAttrCounts('creator')"> </eg-batch-item-attr> - </div> + </li> - <div class="mb-1" *ngIf="displayAttr('edit_date')"> + <li class="mb-1" *ngIf="!templateOnlyMode && displayAttr('edit_date')"> <eg-batch-item-attr label="Last Edit Date" i18n-label [readOnly]="true" [labelCounts]="itemAttrCounts('edit_date')"> </eg-batch-item-attr> - </div> + </li> - <div class="mb-1" *ngIf="displayAttr('editor')"> + <li class="mb-1" *ngIf="!templateOnlyMode && displayAttr('editor')"> <eg-batch-item-attr label="Last Editor" i18n-label [readOnly]="true" [labelCounts]="itemAttrCounts('editor')"> </eg-batch-item-attr> - </div> - + </li> + </ul> </div> <!-- COLUMN 2 --> - <div class="flex-1 p-1"> - <div class="p-1"><h4 class="fw-bold" i18n>Location</h4></div> - - <div *ngIf="displayAttr('location')"> + <div class="col"> + <h4 i18n>Location</h4> + <ul class="list-unstyled"> + <li *ngIf="displayAttr('location')"> <ng-template #locationTemplate> - <eg-item-location-select (valueChange)="values['location'] = $event" - [contextOrgIds]="copyLocationOrgs()" - domId='location-input' [required]="true" permFilter="UPDATE_COPY"> + <eg-item-location-select #locationCombobox (valueChange)="values['location'] = $event" + [contextOrgIds]="copyLocationOrgs()" [startId]="getLocationId(values['location'])" + domId='location-input' [required]="true" permFilter="UPDATE_COPY" + (keydown)="onKeydown('location', $event)"> </eg-item-location-select> </ng-template> <ng-container *ngTemplateOutlet="batchAttr; context:{field:'location',required:true,template:locationTemplate}"> </ng-container> - </div> + </li> - <div *ngIf="displayAttr('circ_lib')"> + <li *ngIf="displayAttr('circ_lib')"> <ng-template #circLibTemplate> - <eg-org-select - domId="circ_lib-input" [ariaLabel]="copyFieldLabel('circ_lib')" + <eg-org-select #circLibCombobox + domId="circ_lib-input" [ariaLabel]="fieldLabel('circ_lib')" (onChange)="values['circ_lib'] = $event ? $event.id() : null" + (orgSelectKey)="onKeydown('circ_lib', $event)" [hideOrgs]="volcopy.hideVolOrgs" - [limitPerms]="['UPDATE_COPY']"> + [limitPerms]="['UPDATE_COPY']" [applyOrgId]="values['circ_lib']"> </eg-org-select> </ng-template> <ng-container *ngTemplateOutlet="batchAttr; context:{field:'circ_lib',required:true,template:circLibTemplate}"> </ng-container> - </div> + </li> - <div *ngIf="displayAttr('owning_lib')"> + <li *ngIf="displayAttr('owning_lib')"> <ng-template #owningLibTemplate> - <eg-org-select + <eg-org-select #owningLibCombobox domId="owning_lib-input" [ariaLabel]="olLabel.text" (onChange)="values['owning_lib'] = $event ? $event.id() : null" - [hideOrgs]="volcopy.hideVolOrgs" - [limitPerms]="['UPDATE_COPY']"> + [hideOrgs]="volcopy.hideVolOrgs" (keydown)="onKeydown('owning_lib', $event)" + [limitPerms]="['UPDATE_COPY']" [applyOrgId]="values['owning_lib']"> </eg-org-select> </ng-template> <ng-container *ngTemplateOutlet="batchAttr; context:{field:'owning_lib',required:true,template:owningLibTemplate,label:olLabel.text}"> </ng-container> - </div> + </li> - <div *ngIf="displayAttr('copy_number')"> + <li *ngIf="displayAttr('copy_number')"> <ng-template #copyNumberTemplate> - <input type="number" class="form-control" - id="copy_number-input" [(ngModel)]="values['copy_number']"/> + <input type="number" class="form-control" aria-labelledby="label-copy_number-input" + id="copy_number-input" [(ngModel)]="values['copy_number']" (keydown)="onKeydown('copy_number', $event)"/> </ng-template> <ng-container *ngTemplateOutlet="batchAttr; context:{field:'copy_number',template:copyNumberTemplate}"> </ng-container> - </div> + </li> + + <li *ngIf="displayAttr('label_class')"> + <ng-template #cnClass> + <eg-combobox domId="label_class-input" ariaLabelledby="label-label_class-input" + (ngModelChange)="values['label_class'] = $event ? $event.id : null" + [ngModel]="values['label_class']" (keydown)="onKeydown('label_class', $event)"> + <eg-combobox-entry + *ngFor="let acnc of volcopy.commonData.acn_class" + [entryId]="acnc.id()" [entryLabel]="acnc.name()" [selected]="values['label_class'] === acnc.id()"> + </eg-combobox-entry> + </eg-combobox> + </ng-template> + <ng-container *ngTemplateOutlet="batchAttr; + context:{field:'label_class',template:cnClass}"> + </ng-container> + </li> + + <li *ngIf="displayAttr('prefix')"> + <ng-template #cnPrefix> + <eg-combobox domId="prefix-input" ariaLabelledby="label-prefix-input" + (ngModelChange)="values['prefix'] = $event ? $event.id : null" + [ngModel]="values['prefix']" (keydown)="onKeydown('prefix', $event)"> + <eg-combobox-entry + *ngFor="let acnp of volcopy.commonData.acn_prefix" + [entryId]="acnp.id()" [entryLabel]="acnp.label()" [selected]="values['prefix'] === acnp.id()"> + </eg-combobox-entry> + </eg-combobox> + </ng-template> + <ng-container *ngTemplateOutlet="batchAttr; + context:{field:'prefix',template:cnPrefix}"> + </ng-container> + </li> + + <li *ngIf="displayAttr('suffix')"> + <ng-template #cnSuffix> + <eg-combobox domId="suffix-input" ariaLabelledby="label-suffix-input" + (ngModelChange)="values['suffix'] = $event ? $event.id : null" + [ngModel]="values['suffix']" (keydown)="onKeydown('suffix', $event)"> + <eg-combobox-entry + *ngFor="let acns of volcopy.commonData.acn_suffix" + [entryId]="acns.id()" [entryLabel]="acns.label()" [selected]="values['suffix'] === acns.id()"> + </eg-combobox-entry> + </eg-combobox> + </ng-template> + <ng-container *ngTemplateOutlet="batchAttr; + context:{field:'suffix',template:cnSuffix}"> + </ng-container> + </li> + </ul> </div> <!-- COLUMN 3 --> - <div class="flex-1 p-1"> - <div class="p-1"><h4 class="fw-bold" i18n>Circulation</h4></div> - - <div *ngIf="displayAttr('circulate')"> + <div class="col"> + <h4 i18n>Circulation</h4> + <ul class="list-unstyled"> + <li *ngIf="displayAttr('circulate')"> <ng-template #circulateTemplate> - <ng-container *ngTemplateOutlet="yesNoSelect;context:{field:'circulate'}"> + <ng-container *ngTemplateOutlet="yesNoSelect;context:{field:'circulate',required:true}"> </ng-container> </ng-template> <ng-container *ngTemplateOutlet="batchAttr; context:{field:'circulate',required:true,template:circulateTemplate,displayAs:'bool'}"> </ng-container> - </div> + </li> - <div *ngIf="displayAttr('holdable')"> + <li *ngIf="displayAttr('holdable')"> <ng-template #holdableTemplate> - <ng-container *ngTemplateOutlet="yesNoSelect;context:{field:'holdable'}"> + <ng-container *ngTemplateOutlet="yesNoSelect;context:{field:'holdable',required:true}"> </ng-container> </ng-template> <ng-container *ngTemplateOutlet="batchAttr; context:{field:'holdable',required:true,template:holdableTemplate,displayAs:'bool'}"> </ng-container> - </div> + </li> - <div *ngIf="displayAttr('age_protect')"> + <li *ngIf="displayAttr('age_protect')"> <ng-template #ageProtectTemplate> - <eg-combobox domId="age_protect-input" + <eg-combobox domId="age_protect-input" ariaLabelledby="label-age_protect-input" (ngModelChange)="values['age_protect'] = $event ? $event.id : null" - [ngModel]="values['age_protect']"> - <eg-combobox-entry + [ngModel]="values['age_protect']" (keydown)="onKeydown('age_protect', $event)"> + <eg-combobox-entry *ngFor="let rule of volcopy.commonData.acp_age_protect" - [entryId]="rule.id()" [entryLabel]="rule.name()"> + [entryId]="rule.id()" [entryLabel]="rule.name()" [selected]="values['age_protect'] === rule.id()"> </eg-combobox-entry> </eg-combobox> </ng-template> <ng-container *ngTemplateOutlet="batchAttr; context:{field:'age_protect',template:ageProtectTemplate}"> </ng-container> - </div> + </li> - <div *ngIf="displayAttr('floating')"> + <li *ngIf="displayAttr('floating')"> <ng-template #floatingTemplate> - <eg-combobox domId="floating-input" + <eg-combobox domId="floating-input" ariaLabelledby="label-floating-input" (ngModelChange)="values['floating'] = $event ? $event.id : null" - [ngModel]="values['floating']"> - <eg-combobox-entry + [ngModel]="values['floating']" (keydown)="onKeydown('floating', $event)"> + <eg-combobox-entry *ngFor="let grp of volcopy.commonData.acp_floating_group" - [entryId]="grp.id()" [entryLabel]="grp.name()"> + [entryId]="grp.id()" [entryLabel]="grp.name()" [selected]="values['floating'] === grp.id()"> </eg-combobox-entry> </eg-combobox> </ng-template> <ng-container *ngTemplateOutlet="batchAttr; context:{field:'floating',template:floatingTemplate}"> </ng-container> - </div> + </li> - <div *ngIf="displayAttr('loan_duration')"> + <li *ngIf="displayAttr('loan_duration')"> <ng-template #loanDurationTemplate> - <select class="form-select" - id="loan_duration-input" [(ngModel)]="values['loan_duration']"> + <select class="form-select" aria-labelledby="label-loan_duration-input" + id="loan_duration-input" [(ngModel)]="values['loan_duration']" (keydown)="onKeydown('loan_duration', $event)"> <option value="1" i18n>{{loanDurationShort.text}}</option> <option value="2" i18n>{{loanDurationNormal.text}}</option> <option value="3" i18n>{{loanDurationLong.text}}</option> @@ -274,12 +343,12 @@ <ng-container *ngTemplateOutlet="batchAttr; context:{field:'loan_duration',required:true,template:loanDurationTemplate}"> </ng-container> - </div> + </li> - <div *ngIf="displayAttr('fine_level')"> + <li *ngIf="displayAttr('fine_level')"> <ng-template #fineLevelTemplate> - <select class="form-select" - id="fine_level-input" [(ngModel)]="values['fine_level']"> + <select class="form-select" aria-labelledby="label-fine_level-input" + id="fine_level-input" [(ngModel)]="values['fine_level']" (keydown)="onKeydown('fine_level', $event)"> <option value="1" i18n>{{fineLevelLow.text}}</option> <option value="2" i18n>{{fineLevelNormal.text}}</option> <option value="3" i18n>{{fineLevelHigh.text}}</option> @@ -288,27 +357,27 @@ <ng-container *ngTemplateOutlet="batchAttr; context:{field:'fine_level',required:true,template:fineLevelTemplate}"> </ng-container> - </div> + </li> - <div *ngIf="displayAttr('circ_as_type')"> + <li *ngIf="displayAttr('circ_as_type')"> <ng-template #circAsTypeTemplate> - <eg-combobox domId="circ_as_type-input" + <eg-combobox domId="circ_as_type-input" ariaLabelledby="label-circ_as_type-input" (ngModelChange)="values['circ_as_type'] = $event ? $event.id : null" - [ngModel]="values['circ_as_type']"> + [ngModel]="values['circ_as_type']" (keydown)="onKeydown('circ_as_type', $event)"> <eg-combobox-entry *ngFor="let map of volcopy.commonData.acp_item_type_map" - [entryId]="map.code()" [entryLabel]="map.value()"> + [entryId]="map.code()" [entryLabel]="map.value()" [selected]="values['circ_as_type'] === map.code()"> </eg-combobox-entry> </eg-combobox> </ng-template> <ng-container *ngTemplateOutlet="batchAttr; context:{field:'circ_as_type',template:circAsTypeTemplate}"> </ng-container> - </div> + </li> - <div *ngIf="displayAttr('circ_modifier')"> + <li *ngIf="displayAttr('circ_modifier')"> <ng-template #circModifierTemplate> - <select class="form-select" id='circ_modifier-input' - [(ngModel)]="values['circ_modifier']"> + <select class="form-select" id='circ_modifier-input' aria-labelledby="label-circ_modifier-input" + [(ngModel)]="values['circ_modifier']" (keydown)="onKeydown('circ_modifier')"> <option [value]="null" i18n><Unset></option> <option *ngFor="let mod of volcopy.commonData.acp_circ_modifier" value="{{mod.code()}}">{{mod.name()}}</option> @@ -317,106 +386,92 @@ <ng-container *ngTemplateOutlet="batchAttr; context:{field:'circ_modifier',template:circModifierTemplate}"> </ng-container> - </div> + </li> + <li *ngIf="displayAttr('opac_visible')"> + <ng-template #opacVisibleTemplate> + <ng-container *ngTemplateOutlet="yesNoSelect;context:{field:'opac_visible',required:true}"> + </ng-container> + </ng-template> + <ng-container *ngTemplateOutlet="batchAttr; + context:{field:'opac_visible',required:true,template:opacVisibleTemplate,displayAs:'bool'}"> + </ng-container> + </li> + + <li *ngIf="displayAttr('ref')"> + <ng-template #refTemplate> + <ng-container *ngTemplateOutlet="yesNoSelect;context:{field:'ref'}"> + </ng-container> + </ng-template> + <ng-container *ngTemplateOutlet="batchAttr; + context:{field:'ref',required:true,template:refTemplate,displayAs:'bool'}"> + </ng-container> + </li> + </ul> </div> <!-- COLUMN 4 --> - <div class="flex-1 p-1"> - <div class="p-1"><h4 class="fw-bold" i18n>Miscellaneous</h4></div> + <div class="col"> + <h4 i18n>Cost and Condition</h4> + <ul class="list-unstyled"> - <!-- Adding this for sites that still use alert messages (we do) - <div> - <ng-template #alertMessageTemplate> - <textarea rows="3" class="form-control" id="alert-message-input" - [(ngModel)]="values['alert_message']"> - </textarea> - </ng-template> - <eg-batch-item-attr label="Alert Message" i18n-label - editInputDomId="alert-message-input" - [readOnly]="!userMayEdit" - [editTemplate]="alertMessageTemplate" - [labelCounts]="itemAttrCounts('alert_message')" - (changesSaved)="applyCopyValue('alert_message')"> - </eg-batch-item-attr> - </div> - --> - - <div class="border rounded m-1" *ngIf="displayAttr('copy_alerts')"> - <eg-copy-alerts-dialog #copyAlertsDialog></eg-copy-alerts-dialog> - <div class="batch-header fw-bold p-2" i18n>Add Item Alerts</div> - <div class="p-1"> - <button type="button" class="btn btn-outline-dark" (click)="openCopyAlerts()" i18n> - Item Alerts - </button> - </div> - </div> - - <div *ngIf="displayAttr('deposit')"> + <li *ngIf="displayAttr('deposit')"> <ng-template #depositTemplate> - <ng-container *ngTemplateOutlet="yesNoSelect;context:{field:'deposit'}"> + <ng-container *ngTemplateOutlet="yesNoSelect;context:{field:'deposit',required:true}"> </ng-container> </ng-template> <ng-container *ngTemplateOutlet="batchAttr; context:{field:'deposit',required:true,template:depositTemplate,displayAs:'bool'}"> </ng-container> - </div> + </li> - <div *ngIf="displayAttr('deposit_amount')"> + <li *ngIf="displayAttr('deposit_amount')"> <ng-template #depositAmountTemplate> - <input type="number" class="form-control" + <label *ngIf="multiValue('deposit_amount')" for="deposit_amount-input" class="form-label" i18n> + Replace selected deposit amounts with: + </label> + <input type="text" inputmode="decimal" class="form-control" (keydown)="onKeydown('deposit_amount', $event)" + [attr.aria-labelledby]="multiValue('deposit_amount') ? null : 'label-deposit_amount-input'" id="deposit_amount-input" [(ngModel)]="values['deposit_amount']"/> </ng-template> <ng-container *ngTemplateOutlet="batchAttr; context:{field:'deposit_amount',required:true,template:depositAmountTemplate,displayAs:'currency'}"> </ng-container> - </div> + </li> - <div *ngIf="displayAttr('price')"> + <li *ngIf="displayAttr('price')"> <ng-template #priceTemplate> - <input type="number" class="form-control" + <label *ngIf="multiValue('price')" for="price-input" class="form-label" i18n> + Replace selected prices with: + </label> + <input type="text" inputmode="decimal" class="form-control" (keydown)="onKeydown('price', $event)" + [attr.aria-labelledby]="multiValue('price') ? null : 'label-price-input'" id="price-input" [(ngModel)]="values['price']"/> </ng-template> <ng-container *ngTemplateOutlet="batchAttr; context:{field:'price',template:priceTemplate,displayAs:'currency'}"> </ng-container> - </div> - - <div *ngIf="displayAttr('opac_visible')"> - <ng-template #opacVisibleTemplate> - <ng-container *ngTemplateOutlet="yesNoSelect;context:{field:'opac_visible'}"> - </ng-container> - </ng-template> - <ng-container *ngTemplateOutlet="batchAttr; - context:{field:'opac_visible',required:true,template:opacVisibleTemplate,displayAs:'bool'}"> - </ng-container> - </div> + </li> - <div *ngIf="displayAttr('ref')"> - <ng-template #refTemplate> - <ng-container *ngTemplateOutlet="yesNoSelect;context:{field:'ref'}"> - </ng-container> - </ng-template> - <ng-container *ngTemplateOutlet="batchAttr; - context:{field:'ref',required:true,template:refTemplate,displayAs:'bool'}"> - </ng-container> - </div> - - <div *ngIf="displayAttr('cost')"> + <li *ngIf="displayAttr('cost')"> <ng-template #costTemplate> - <input type="number" class="form-control" + <label *ngIf="multiValue('cost')" for="cost-input" class="form-label" i18n> + Replace selected costs with: + </label> + <input type="text" inputmode="decimal" class="form-control" (keydown)="onKeydown('cost', $event)" + [attr.aria-labelledby]="multiValue('cost') ? null : 'label-cost-input'" id="cost-input" [(ngModel)]="values['cost']"/> </ng-template> <ng-container *ngTemplateOutlet="batchAttr; context:{field:'cost',template:costTemplate,displayAs:'currency'}"> </ng-container> - </div> + </li> - <div *ngIf="displayAttr('mint_condition')"> + <li *ngIf="displayAttr('mint_condition')"> <ng-template #mintConditionTemplate> - <select class="form-select" - id="mint_condition-input" [(ngModel)]="values['mint_condition']"> + <select class="form-select" aria-labelledby="label-mint_condition-input" + id="mint_condition-input" [(ngModel)]="values['mint_condition']" (keydown)="onKeydown('mint_condition', $event)"> <option value="t" i18n>{{mintConditionYes.text}}</option> <option value="f" i18n>{{mintConditionNo.text}}</option> </select> @@ -424,70 +479,316 @@ <ng-container *ngTemplateOutlet="batchAttr; context:{field:'mint_condition',template:mintConditionTemplate}"> </ng-container> - </div> - + </li> + </ul> </div> - <!-- COLUMN 5 --> - <div class="flex-1 p-1"> - <div class="p-1"><h4 class="fw-bold" i18n>Statistics</h4></div> - - <div class="border rounded m-1" *ngIf="displayAttr('copy_tags')"> - <eg-copy-tags-dialog #copyTagsDialog></eg-copy-tags-dialog> - <div class="batch-header fw-bold p-2" i18n>Add Item Tags</div> - <div class="p-1"> - <button type="button" class="btn btn-outline-dark" (click)="openCopyTags()" i18n> - Item Tags - </button> + <div id="col-text-fields"> <!-- container for alerts, notes, tags, and stats --> + <h3 id="col-text-fields-header" i18n>Alerts, Notes, Tags, Statistics</h3> + <!-- hush, eslint, we have focusable click handlers on the <button> --> + <!-- eslint-disable @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/accessibility-interactive-supports-focus --> + + <!-- COLUMN 5 --> + <div class="col" *ngIf="displayAttr('copy_alerts')" (click)="openCopyAlerts($event)"> + <h4 i18n>Alerts</h4> + <ul class="list-unstyled"> + <!-- Adding this for sites that still use alert messages (we do) + <div> + <ng-template #alertMessageTemplate> + <textarea rows="3" class="form-control" id="alert-message-input" + [(ngModel)]="values['alert_message']"> + </textarea> + </ng-template> + <eg-batch-item-attr label="Alert Message" i18n-label + editInputDomId="alert-message-input" + [readOnly]="!userMayEdit" + [editTemplate]="alertMessageTemplate" + [labelCounts]="itemAttrCounts('alert_message')" + (changesSaved)="applyCopyValue('alert_message')"> + </eg-batch-item-attr> </div> + --> + <li> + <div class="card copy_alerts" + [ngClass]="{'has-changes': alertsHaveChanged() }"> + <eg-copy-alerts-dialog #copyAlertsDialog></eg-copy-alerts-dialog> + <div class="card-header"> <!-- for inline display only --> + <h5 class="field-heading"> + <button type="button" class="btn-link edit-toggle" (click)="openCopyAlerts($event)" i18n> + Alerts + </button> + </h5> + </div> + <div class="card-body"> + <ul class="list-unstyled ps-1"> + + + <!-- Existing alerts --> + + <ng-container *ngIf="!templateOnlyMode"> + <li *ngIf="context.copyList().length === 1"> + <div role="text"> + <span i18n>{context.copyList()[0].copy_alerts().length, plural, + =0 {No alerts} + =1 {1 alert} + other {{{context.copyList()[0].copy_alerts().length}} alerts}} + </span> + <span *ngIf="hasDisabledAlerts(context.copyList()[0].copy_alerts())" class="badge badge-danger ms-1" i18n> + (Warning: inactive alert types present) + </span> + </div> + </li> + <li *ngIf="context.copyList().length > 1"> + <div role="text"> + <span i18n>{copyAlertsDialog.alertsInCommon.length, plural, + =0 {No matching alerts} + =1 {1 matching alert} + other {{{copyAlertsDialog.alertsInCommon.length}} matching alerts}} + </span> + <span *ngIf="hasDisabledAlerts(copyAlertsDialog.alertsInCommon)" class="badge badge-danger ms-1" i18n> + (Warning: inactive alert types present) + </span> + </div> + </li> + </ng-container> + + <!-- Pending changes --> + <ng-container *ngIf="context.newAlerts?.length"> + <li> + <div role="text"> + <span i18n>{context.newAlerts.length, plural, + =1 {1 new alert} + other {{{context.newAlerts.length}} new alerts}} + </span> + <span *ngIf="hasDisabledAlerts(context.newAlerts)" class="badge badge-danger ms-1" i18n> + (Warning: inactive alert types present) + </span> + </div> + </li> + </ng-container> + + <ng-container *ngIf="context.changedAlerts?.length"> + <li> + <div role="text"> + <span i18n> + {context.changedAlerts.length, plural, + =1 {1 modified alert} + other {{{context.changedAlerts.length}} modified alerts}} + </span> + <span *ngIf="hasClearedAlerts()" i18n> + ({getClearedAlertCount(), plural, + =1 {1 cleared} + other {{{getClearedAlertCount()}} cleared}}) + </span> + <span *ngIf="hasDisabledAlerts(context.changedAlerts)" class="badge badge-danger" i18n> + (Warning: inactive alert types present) + </span> + </div> + </li> + </ng-container> + + <!-- shouldn't see this, but for copy/paste goodness; must clear alerts --> + <ng-container *ngIf="context.deletedAlerts?.length"> + <li i18n> + {context.deletedAlerts.length, plural, + =1 {1 deleted alert} + other {{{context.deletedAlerts.length}} deleted alerts}} + </li> + </ng-container> + </ul> + <button type="button" class="btn btn-outline-dark" (click)="openCopyAlerts($event)" i18n> + Edit Item Alerts + </button> + </div> + </div> + </li> + </ul> </div> - <div class="border rounded m-1" *ngIf="displayAttr('copy_notes')"> - <eg-copy-notes-dialog #copyNotesDialog></eg-copy-notes-dialog> - <div class="batch-header fw-bold p-2" i18n>Add Item Notes</div> - <div class="p-1"> - <button type="button" class="btn btn-outline-dark" (click)="openCopyNotes()" i18n> - Item Notes - </button> + <div class="col" *ngIf="displayAttr('copy_tags')" (click)="openCopyTags($event)"> + <h4 i18n>Tags</h4> + <ul class="list-unstyled"> + <li> + <div class="card copy_tags" + [ngClass]="{'has-changes': tagsHaveChanged() }"> + <eg-copy-tags-dialog #copyTagsDialog></eg-copy-tags-dialog> + <div class="card-header"> <!-- for inline display only --> + <h5 class="field-heading"> + <button type="button" class="btn-link edit-toggle" (click)="openCopyTags($event);" i18n> + Tags + </button> + </h5> + </div> + <div class="card-body"> + <ul class="list-unstyled ps-1"> + <!-- Existing tags --> + + <ng-container *ngIf="!templateOnlyMode"> + <li *ngIf="context.copyList().length === 1" i18n> + {context.copyList()[0].tags().length, plural, + =0 {No tags} + =1 {1 tag} + other {{{context.copyList()[0].tags().length}} tags}} + </li> + <li *ngIf="context.copyList().length > 1" i18n> + {countTotalTags(), plural, + =0 {No matching tags} + =1 {1 matching tag} + other {{{countTotalTags()}} matching tags}} + </li> + </ng-container> + + <!-- Pending changes --> + <ng-container *ngIf="context.newTagMaps?.length"> + <li i18n> + {this.context.newTagMaps.length, plural, + =1 {1 tag added} + other {{{this.context.newTagMaps.length}} tags added}} + </li> + </ng-container> + + <ng-container *ngIf="context.changedTagMaps?.length"> + <li i18n> + {this.context.changedTagMaps.length, plural, + =1 {1 tag modified} + other {{{this.context.changedTagMaps.length}} tags modified}} + </li> + </ng-container> + + <ng-container *ngIf="context.deletedTagMaps?.length"> + <li i18n> + {this.context.deletedTagMaps.length, plural, + =1 {1 tag deleted} + other {{{this.context.deletedTagMaps.length}} tags deleted}} + </li> + </ng-container> + </ul> + <button type="button" class="btn btn-outline-dark" (click)="openCopyTags($event)" i18n> + Edit Item Tags + </button> + </div> </div> - </div> + </li> + </ul> + </div> - <div class="border rounded m-1" *ngIf="displayAttr('statcat_filter')"> - <div class="batch-header font-weight-bold p-2"> - <label for="statcat_filter-select" class="form-label" i18n>Stat Cat Filter</label> + <div class="col" *ngIf="displayAttr('copy_notes')" (click)="openCopyNotes($event)"> + <h4 i18n>Notes</h4> + <ul class="list-unstyled"> + <li> + <div class="card copy_notes" [ngClass]="{'has-changes': notesHaveChanged() }"> + <eg-copy-notes-dialog #copyNotesDialog></eg-copy-notes-dialog> + + <div class="card-header"> <!-- for inline display only --> + <h5 class="field-heading"> + <button type="button" class="btn-link edit-toggle" (click)="openCopyNotes($event)" i18n> + Notes + </button> + </h5> + </div> + <div class="card-body"> + <ul class="list-unstyled ps-1"> + <!-- Existing notes --> + + <ng-container *ngIf="!templateOnlyMode"> + <li *ngIf="context.copyList().length === 1" i18n> + {context.copyList()[0].notes().length, plural, + =0 {No notes} + =1 {1 note} + other {{{context.copyList()[0].notes().length}} notes}} + </li> + <li *ngIf="context.copyList().length > 1" i18n> + {copyNotesDialog.notesInCommon.length, plural, + =0 {No matching notes} + =1 {1 matching notes} + other {{{copyNotesDialog.notesInCommon.length}} matching notes}} + </li> + </ng-container> + + <!-- Pending changes --> + <ng-container *ngIf="context.newNotes?.length"> + <li i18n> + {context.newNotes.length, plural, + =1 {1 new note} + other {{{context.newNotes.length}} new notes}} + </li> + </ng-container> + + <ng-container *ngIf="context.changedNotes?.length"> + <li> + <span i18n> + {context.changedNotes.length, plural, + =1 {1 modified note} + other {{{context.changedNotes.length}} modified notes}} + </span> + </li> + </ng-container> + + <ng-container *ngIf="context.deletedNotes?.length"> + <li> + <span i18n> + {context.deletedNotes.length, plural, + =1 {1 deleted note} + other {{{context.deletedNotes.length}} deleted notes}} + </span> + </li> + </ng-container> + </ul> + <button type="button" class="btn btn-outline-dark" (click)="openCopyNotes($event)" i18n> + Edit Item Notes + </button> + </div> </div> - <div class="p-1"> - <eg-org-select - domId="statcat_filter-select" - placeholder="Stat Cat Filter..." i18n-placeholder - [initialOrgId]="statCatFilter" - (onChange)="statCatFilter = $event ? $event.id() : null"> - </eg-org-select> - </div> - </div> + </li> + </ul> + </div> - <ng-container *ngIf="displayAttr('statcats')"> - <div *ngFor="let cat of statCats()"> - <ng-template #statCatTemplate> - <eg-combobox domId="stat-cat-input-{{cat.id()}}" - (ngModelChange)="statCatValues[cat.id()] = $event ? $event.id : null" - [ngModel]="statCatValues[cat.id()]"> - <eg-combobox-entry *ngFor="let entry of cat.entries()" - [entryId]="entry.id()" [entryLabel]="entry.value()"> - </eg-combobox-entry> - </eg-combobox> - </ng-template> - <eg-batch-item-attr label="{{cat.name()}} ({{orgSn(cat.owner())}})" i18n-label - name="stat_cat_{{cat.id()}}" editInputDomId="stat-cat-input-{{cat.id()}}" - [readOnly]="!userMayEdit" - [valueRequired]="cat.required() === 't'" - [editTemplate]="statCatTemplate" - [labelCounts]="statCatCounts(cat.id())" - (valueCleared)="statCatChanged(cat.id(), true)" - (changesSaved)="statCatChanged(cat.id())"> - </eg-batch-item-attr> - </div> - </ng-container> + <div class="col"> + <h4 i18n>Statistics</h4> + <ul class="list-unstyled"> + + <li *ngIf="displayAttr('statcat_filter')"> + <div class="card copy_stats"> + <div class="card-header"> + <h5 class="field-heading label-ref-input"> + <label for="statcat_filter-select" class="form-label m-0" i18n>Stat Cat Filter</label> + </h5> + </div> + <div class="card-body"> + <eg-org-select + domId="statcat_filter-select" + placeholder="Stat Cat Filter..." i18n-placeholder + [initialOrgId]="statCatFilter" (keydown.enter)="save('statcat_filter')" (keydown.escape)="cancel('statcat_filter')" + (onChange)="statCatFilter = $event ? $event.id() : null"> + </eg-org-select> + </div> + </div> + </li> + + <ng-container *ngIf="displayAttr('statcats')"> + <li *ngFor="let cat of statCats()"> + <ng-template #statCatTemplate> + <eg-combobox domId="stat-cat-input-{{cat.id()}}" ariaLabel="{{cat.name()}}" + (ngModelChange)="statCatValues[cat.id()] = $event ? $event.id : null" + [ngModel]="statCatValues[cat.id()]" (keydown)="onKeydown(cat.id(), $event)"> + <eg-combobox-entry *ngFor="let entry of cat.entries()" + [entryId]="entry.id()" [entryLabel]="entry.value()" [selected]="statCatValues[cat.id()] === entry.id()"> + </eg-combobox-entry> + </eg-combobox> + </ng-template> + <eg-batch-item-attr label="{{cat.name()}} ({{orgSn(cat.owner())}})" i18n-label + name="stat_cat_{{cat.id()}}" editInputDomId="stat-cat-input-{{cat.id()}}" + [readOnly]="!userMayEdit" + [valueRequired]="cat.required() === 't'" + [editTemplate]="statCatTemplate" + [labelCounts]="statCatCounts(cat.id())" + (valueCleared)="valueClearedForStatCat(cat.id())" + (changesSaved)="statCatChanged(cat.id())"> + </eg-batch-item-attr> + </li> + </ng-container> + </ul> + </div> </div> </div> diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.spec.ts b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.spec.ts index ae37c298c1..465bb742f6 100644 --- a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.spec.ts +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.spec.ts @@ -7,7 +7,6 @@ import { OrgService } from '@eg/core/org.service'; import { StoreService } from '@eg/core/store.service'; import { ComboboxComponent } from '@eg/share/combobox/combobox.component'; import { ToastService } from '@eg/share/toast/toast.service'; -import { FileExportService } from '@eg/share/util/file-export.service'; import { CopyAttrsComponent } from './copy-attrs.component'; import { VolCopyContext, HoldingsTreeNode } from './volcopy'; import { VolCopyService } from './volcopy.service'; @@ -20,13 +19,12 @@ describe('CopyAttrsComponent', () => { const authServiceMock = jasmine.createSpyObj<AuthService>(['user']); const formatServiceMock = jasmine.createSpyObj<FormatService>(['transform']); const storeServiceMock = jasmine.createSpyObj<StoreService>(['setLocalItem']); - const fileExportServiceMock = jasmine.createSpyObj<FileExportService>(['exportFile']); const toastServiceMock = jasmine.createSpyObj<ToastService>(['success']); const volCopyServiceMock = jasmine.createSpyObj<VolCopyService>(['copyStatIsMagic', 'saveTemplates']); beforeEach(() => { component = new CopyAttrsComponent(idlMock, orgMock, authServiceMock, - null, formatServiceMock, storeServiceMock, fileExportServiceMock, + null, formatServiceMock, storeServiceMock, toastServiceMock, volCopyServiceMock); component.copyTemplateCbox = jasmine.createSpyObj<ComboboxComponent>(['entries']); component.copyTemplateCbox.selected = {id: 0}; @@ -125,7 +123,7 @@ describe('CopyAttrsComponent', () => { // Also assume that we have no item fields component.batchAttrs = new QueryList(); - component.saveTemplate(); + component.saveTemplate(false); expect(component.volcopy.templates[0]).toEqual({callnumber: {prefix: 10}}); }); @@ -155,7 +153,7 @@ describe('CopyAttrsComponent', () => { // Also assume that we have no item fields component.batchAttrs = new QueryList(); - component.saveTemplate(); + component.saveTemplate(false); expect(component.volcopy.templates[0]).toEqual({callnumber: {prefix: 10, classification: 1}}); }); diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.ts index fb6eb8ca53..ffde55270b 100644 --- a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.ts @@ -1,22 +1,25 @@ /* eslint-disable no-case-declarations, no-magic-numbers, no-shadow */ +/* eslint-disable max-len, no-prototype-builtins */ import {Component, Input, OnInit, OnDestroy, AfterViewInit, ViewChild, EventEmitter, Output, QueryList, ViewChildren} from '@angular/core'; -import {Subscription,Observable} from 'rxjs'; +import {firstValueFrom,BehaviorSubject,Subject,Subscription,Observable} from 'rxjs'; +import {take,takeUntil,filter} from 'rxjs/operators'; import {SafeUrl} from '@angular/platform-browser'; import {IdlObject, IdlService} from '@eg/core/idl.service'; import {OrgService} from '@eg/core/org.service'; import {StoreService} from '@eg/core/store.service'; import {AuthService} from '@eg/core/auth.service'; import {PermService} from '@eg/core/perm.service'; +import {PcrudService} from '@eg/core/pcrud.service'; import {VolCopyContext} from './volcopy'; import {VolCopyService} from './volcopy.service'; import {FormatService} from '@eg/core/format.service'; import {StringComponent} from '@eg/share/string/string.component'; -import {CopyAlertsDialogComponent +import {ICopyAlert,CopyAlertsDialogComponent } from '@eg/staff/share/holdings/copy-alerts-dialog.component'; -import {CopyTagsDialogComponent +import {ICopyTagMap, CopyTagsDialogComponent } from '@eg/staff/share/holdings/copy-tags-dialog.component'; -import {CopyNotesDialogComponent +import {ICopyNote, CopyNotesDialogComponent } from '@eg/staff/share/holdings/copy-notes-dialog.component'; import {ComboboxComponent, ComboboxEntry} from '@eg/share/combobox/combobox.component'; import {BatchItemAttrComponent, BatchChangeSelection @@ -27,18 +30,26 @@ import {ToastService} from '@eg/share/toast/toast.service'; @Component({ selector: 'eg-copy-attrs', templateUrl: 'copy-attrs.component.html', - - // Match the header of the batch attrs component - styles: [ - '.batch-header {background-color: var(--batch-item-attr-header-bg);}', - '.template-row {background-color: #007a54;}' - ] + styleUrls: ['copy-attrs.component.css'] }) export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { @Input() context: VolCopyContext; @Input() contextChanged: Observable<VolCopyContext>; - private contextSubscription: Subscription; + @Input() templateOnlyMode = false; // expect to use this for styling + @Input() template: string; // in templateOnlyMode, the name of the template we're editing + showSaveInEditor = false; // overriden by an org setting + hideTemplateBar = false; + yesNoOptions = [ + { label: $localize`Yes`, value: 't' }, + { label: $localize`No`, value: 'f' }, + ]; + + private _initialized$ = new BehaviorSubject<boolean>(false); + public initialized$ = new BehaviorSubject<boolean>(false); + private destroy$ = new Subject<void>(); + private originalCopies: {[id: number]: IdlObject} = {}; + private originalVols: {[id: number]: IdlObject} = {}; // Batch values applied from the form. // Some values are scalar, some IdlObjects depending on copy fleshyness. @@ -94,6 +105,9 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { // Emitted when the save-ability of this form changes. @Output() canSaveChange: EventEmitter<boolean> = new EventEmitter<boolean>(); + // Emitted when the Clear Changes action is used. + @Output() clearChanges: EventEmitter<boolean> = new EventEmitter<boolean>(); + userMayEdit = true; constructor( @@ -101,35 +115,203 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { private org: OrgService, private auth: AuthService, private perm: PermService, + private pcrud: PcrudService, private format: FormatService, private store: StoreService, - private fileExport: FileExportService, private toast: ToastService, public volcopy: VolCopyService ) { } ngOnInit() { + // console.debug('CopyAttrsComponent, ngOnInit, this', this); + + this.org.settings([ + 'ui.cat.volume_copy_editor.template_bar.show_save_template', + 'ui.cat.volume_copy_editor.hide_template_bar' + ], this.auth.user().ws_ou()) + .then(settings => { + this.showSaveInEditor = Boolean(settings['ui.cat.volume_copy_editor.template_bar.show_save_template']); + this.hideTemplateBar = Boolean(settings['ui.cat.volume_copy_editor.hide_template_bar']); + }); + + // Wait for volcopy service to be ready + if (this.volcopy.defaults) { + this.initialize(); + } else { + // Wait for the parent's load() to complete + this.contextChanged.pipe( + filter(() => !!this.volcopy.defaults), // Only proceed when defaults exist + takeUntil(this.destroy$) + ).subscribe(() => { + if (!this._initialized$.value) { + this.initialize(); + } + }); + } + } + + private initialize() { this.handleBroadcasts(); this.setDefaults(); this.evaluatePermissions(); + + // this.presetWidgetsInNonBatchMode(); + this.backupOriginalState(); + this._initialized$.next(true); } - ngOnDestroy() { - if (this.contextSubscription) { - this.contextSubscription.unsubscribe(); + public presetWidgets() { + if (this.templateOnlyMode) { + this.values = this.context.copyList().length ? this.idl.toHash( this.context.copyList()[0] ) : {}; + // console.debug('CopyAttrsComponent, leaving presetWidgets as template with values:', this.values); + return; + } + + // don't fetch values for hidden fields or anything we can't edit here + const hidden = this.volcopy.defaults?.hidden; + const editable = Array('age_protect', 'barcode', 'call_number', 'circ_as_type', + 'circ_lib', 'circ_modifier', 'circulate', 'cost', 'deposit', 'deposit_amount', + 'fine_level', 'floating', 'holdable', 'loan_duration', 'location', 'mint_condition', + 'opac_visible', 'price', 'ref', 'status'); + const callNumberPieces = Array(); + Array('label_class','owning_lib','prefix','suffix').forEach(key => { + if (this.displayAttr(key)) {callNumberPieces.push(key);} + }); + + // console.debug('CopyAttrsComponent, entering presetWidgets, template only mode?', this.templateOnlyMode); + const multiValueFields = Array(); + const copies = this.context.copyList(); + copies.forEach(copy => { + if (copy.ischanged() && copy.ischanged !== 'f') { + // console.debug('CopyAttrsComponent, presetWidgets, copy marked as changed, aborting', copy); + return; + } + editable.forEach(field => { + if (copy[field] && !hidden[field] && !multiValueFields.includes(field)) { + let newval; + switch (field) { + case 'call_number': + newval = Object.fromEntries(callNumberPieces + .filter(key => key in copy.call_number()) + .map(key => [key, copy.call_number()[key]()]) + ); + // console.debug("Call number: ", newval); + break; + case 'location': + newval = this.getLocationId(copy[field]()); + // console.debug('Location: ', newval); + break; + default: + newval = copy[field](); + } + + // console.debug("CopyAttrsComponent, presetWidgets, loaded value:", field, newval); + // do we have multiple values? + if (field !== 'call_number' && this.values[field] && this.values[field] !== newval) { + this.values[field] = null; + multiValueFields.push(field); + } else { + if (field === 'call_number' && this.values[field] && !this.compareCallNumbers(this.values[field], newval)) { + this.values[field] = null; + multiValueFields.push(field); + } else { + this.values[field] = newval; + // console.debug("CopyAttrsComponent, presetWidgets, values['" + field + "'] set to: ", newval); + } + } + } + }); + + // set up defaults only if we are not in templates + const defaults = this.templateOnlyMode ? this.volcopy.defaults?.values : []; + + // start with defaults, add values, promote call number pieces into the parent object + this.values = { ...defaults, ...this.values, ...this.values['call_number'] }; + // fix name mismatch in call number label classification + if (!this.values['label_class'] && defaults['classification']) { + this.values['label_class'] = defaults['classification']; + } + }); + + // console.debug('CopyAttrsComponent, leaving presetWidgets with values:', this.values); + // console.debug('CopyAttrsComponent, leaving presetWidgets with multiValueFields:', multiValueFields); + } + + public compareCallNumbers(cn1, cn2) { + if (typeof cn1 !== 'object' || typeof cn2 !== 'object') {return false;} + + if (Object.keys(cn1).length === Object.keys(cn2).length && Object.values(cn1).toString() === Object.values(cn2).toString()) {return true;} + + return false; + } + + public presetWidgetsInNonBatchMode() { + // console.debug('CopyAttrsComponent, entering presetWidgetsInNonBatchMode'); + const copies = this.context.copyList(); + if (copies.length === 1) { + const copy = this.context.copyList()[0]; + // console.debug('CopyAttrsComponent, presetWidgetsInNonBatchMode, found single copy:', copy); + if (copy.ischanged() && copy.ischanged !== 'f') { + // console.debug('CopyAttrsComponent, presetWidgetsInNonBatchMode, copy marked as changed, aborting', copy); + return; + } + this.idl.classes.acp.fields.forEach(field => { + if (copy[field.name]) { + this.values[field.name] = copy[field.name](); + // console.debug("CopyAttrsComponent, presetWidgetsInNonBatchMode, values['" + field.name + "'] set to: ", this.values[field.name]); + } + }); + // get call number pieces out of the object + ['label','label_class','owning_lib','prefix','suffix'].forEach(field =>{ + if (this.values['call_number'][field]()) { + this.values[field] = this.values['call_number'][field](); + } + }); + // we need the shelving location ID, not the whole object + this.values['location'] = this.values['location']?.id() || 1; + } else { + console.debug('CopyAttrsComponent, presetWidgetsInNonBatchMode, found ' + copies.length + ' copies:', copies); } + console.debug('CopyAttrsComponent, leaving presetWidgetsInNonBatchMode with values:', this.values); + } + + private backupOriginalState() { + if (!this.context) {return;} + + // Backup copies + this.context.copyList().forEach(copy => { + const copyClone = this.idl.clone(copy); + const copyId = copy.id(); + this.originalCopies[copyId] = copyClone; + // console.debug('originalCopies['+copyId+'] = ',copyClone); + }); + + // Backup volumes + this.context.volNodes().forEach(volNode => { + const volClone = this.idl.clone(volNode.target); // acn + const volId = volNode.target.id(); + this.originalVols[volId] = volClone; + // console.debug('originalVols['+volId+'] = ',volClone); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } handleBroadcasts() { if (this.context) { - this.contextSubscription = this.contextChanged.subscribe(updatedContext => { + this.contextChanged.pipe( takeUntil(this.destroy$) ).subscribe(updatedContext => { this.evaluatePermissions(); }); } } setDefaults() { - this.statCatFilter = this.volcopy.defaults.values.statcat_filter; + if (this.volcopy.defaults?.values) { + this.statCatFilter = this.volcopy.defaults?.values.statcat_filter; + } } evaluatePermissions() { @@ -144,13 +326,32 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { }); } + saveTemplateCboxSelection(entry) { + // console.debug('saveTemplateCboxSelection', entry); + if (entry && !entry.freetext) { + this.store.setLocalItem('cat.copy.last_template_selected', + entry ? (entry.freetext ? entry.label : entry.id) : null); + this.resetTemplateCboxSelection(); // make sure UI and model are in sync + } + } + + resetTemplateCboxSelection() { + const tmpl = this.store.getLocalItem('cat.copy.last_template_selected'); + // console.debug('resetTemplateCboxSelection', tmpl); + // avoid Express Changed warning w/ timeout + setTimeout(() => { + this.copyTemplateCbox.selectedId = tmpl; + /* + console.debug('CopyAttrsComponent, copyTemplateCbox.selected is now', + this.copyTemplateCbox, this.copyTemplateCbox.selected); + /** */ + }); + } + ngAfterViewInit() { + // console.debug('CopyAttrsComponent, ngAfterViewInit, this', this); - const tmpl = this.store.getLocalItem('cat.copy.last_template'); - if (tmpl) { - // avoid Express Changed warning w/ timeout - setTimeout(() => this.copyTemplateCbox.selectedId = tmpl); - } + if (this.template !== null) {this.resetTemplateCboxSelection();} this.loanDurationLabelMap[1] = this.loanDurationShort.text; this.loanDurationLabelMap[2] = this.loanDurationNormal.text; @@ -160,6 +361,18 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { this.fineLevelLabelMap[2] = this.fineLevelNormal.text; this.fineLevelLabelMap[3] = this.fineLevelHigh.text; + this.presetWidgets(); + this.initCopyAlerts(); + this.initCopyTags(); + this.initCopyNotes(); + + this._initialized$.pipe( + filter(initialized => initialized), + take(1) + ).subscribe( () => { + // console.debug('CopyAttrsComponent, emitting initialized$ to the outside world'); + this.initialized$.next(true); + }); } statCats(): IdlObject[] { @@ -185,7 +398,7 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { const counts = {}; this.context.copyList().forEach(copy => { - const entry = copy.stat_cat_entries() + const entry = (copy.stat_cat_entries() || []) .filter(e => e.stat_cat() === catId)[0]; let value = ''; @@ -222,76 +435,152 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { return counts; } - getFieldDisplayValue(field: string, copy: IdlObject): string { + multiValue(field: string): boolean { + return Object.keys(this.itemAttrCounts(field)).length > 1; + } - // Some fields don't live directly on the copy. - if (field === 'owning_lib') { - return this.org.get( - copy.call_number().owning_lib()).shortname() + - ' : ' + copy.call_number().label(); + // sometimes we get the whole location object from the template. This is bad. + getLocationId(value: any): number { + // console.debug('location ID ', value); + if (typeof value === 'object') { + return Number(this.idl.pkeyValue(value)); } - const value = copy[field](); - - if (!value && value !== 0) { return ''; } - - switch (field) { - - case 'status': - return this.volcopy.copyStatuses[value].name(); - - case 'location': - return value.name() + - ' (' + this.org.get(value.owning_lib()).shortname() + ')'; - - case 'edit_date': - case 'create_date': - case 'active_date': - return this.format.transform( - {datatype: 'timestamp', value: value}); - - case 'editor': - case 'creator': - // VIEW_USER permission may be too narrow. If so, - // just display the user ID instead of the username. - return typeof value === 'object' ? value.usrname() : value; + return Number(value); + } - case 'circ_lib': - return this.org.get(value).shortname(); + getFieldDisplayValue(field: string, _copy: IdlObject): string { - case 'age_protect': - const rule = this.volcopy.commonData.acp_age_protect.filter( - r => r.id() === Number(value))[0]; - return rule ? rule.name() : ''; + try { + const copy = this.idl.fromHash( _copy, 'acp' ); // "defensive" coding, aka, look into why later + // console.debug('getFieldDisplayValue',this.volcopy,field,copy); - case 'floating': - const grp = this.volcopy.commonData.acp_floating_group.filter( - g => g.id() === Number(value))[0]; - return grp ? grp.name() : ''; + // Some fields don't live directly on the copy. + switch (field) { + case 'owning_lib': + // the IDL-generated copy sets a blank owning_lib to the workstation + // we don't want that in templates; only owning_lib actually saved in the template + if (this.templateOnlyMode && !this.values['owning_lib']) { + return ''; + } - case 'loan_duration': - return this.loanDurationLabelMap[value]; + let lib = this.org.get(copy.call_number().owning_lib()).shortname(); + // call number labels can be blank in templates + if (copy.call_number().label()) { + lib = lib + ' : ' + copy.call_number().label(); + } + return lib; + case 'prefix': + const actual_prefix = copy.call_number().prefix(); + const fleshed_prefix = this.volcopy.acnPrefixes[ actual_prefix ]; + const stringified_prefix = fleshed_prefix?.label() || ''; + return stringified_prefix; + case 'suffix': + const actual_suffix = copy.call_number().suffix(); + const fleshed_suffix = this.volcopy.acnSuffixes[ actual_suffix ]; + const stringified_suffix = fleshed_suffix?.label() || ''; + return stringified_suffix; + case 'label_class': + const actual_label_class = copy.call_number().label_class(); + const fleshed_label_class = this.volcopy.acnLabelClasses[ actual_label_class ]; + const stringified_label_class = fleshed_label_class?.name() || ''; + return stringified_label_class; + } - case 'fine_level': - return this.fineLevelLabelMap[value]; + const value = copy[field](); + let v = this.idl.toHash( value ); // offside? + + if (!value && value !== 0) { return ''; } + + switch (field) { + + case 'status': + return this.volcopy.copyStatuses[value].name(); + + case 'location': + // console.debug('Location value, v: ', value, v); + // console.debug('Field display value, starting with location: ', value, v); + let owning; + let name; + if (typeof value === 'number') { + if (typeof v !== 'object') { + v = this.volcopy.getLocation(value).then( + loc => { + name = loc.name(); + owning = loc.owning_lib(); + } + ); + } else { + name = v.name; + owning = v.owning_lib; + } + // console.debug('Field display value, owning from first if: ', owning, name); + } else { + // if value is an object, we should have everything we need + owning = value.owning_lib(); + name = value.name(); + // console.debug('Field display value, owning from value: ', owning, name); + } - case 'circ_as_type': - const map = this.volcopy.commonData.acp_item_type_map.filter( - m => m.code() === value)[0]; - return map ? map.value() : ''; + const shortname = this.org.get(owning)?.shortname(); + if (shortname) { + return name + ` (${shortname})`; + } - case 'circ_modifier': - const mod = this.volcopy.commonData.acp_circ_modifier.filter( - m => m.code() === value)[0]; - return mod ? mod.name() : ''; + return name; + case 'edit_date': + case 'create_date': + case 'active_date': + return this.format.transform( + {datatype: 'timestamp', value: value}); + + case 'editor': + case 'creator': + // VIEW_USER permission may be too narrow. If so, + // just display the user ID instead of the username. + if (typeof value === 'string' || typeof value === 'number') { return value.toString(); } + return v.usrname; + + case 'circ_lib': + return this.org.get(value).shortname(); + + case 'age_protect': + const rule = this.volcopy.commonData.acp_age_protect.filter( + r => r.id() === Number(value))[0]; + return rule ? rule.name() : ''; + + case 'floating': + const grp = this.volcopy.commonData.acp_floating_group.filter( + g => g.id() === Number(value))[0]; + return grp ? grp.name() : ''; + + case 'loan_duration': + return this.loanDurationLabelMap[value]; + + case 'fine_level': + return this.fineLevelLabelMap[value]; + + case 'circ_as_type': + const map = this.volcopy.commonData.acp_item_type_map.filter( + m => m.code() === value)[0]; + return map ? map.value() : ''; + + case 'circ_modifier': + const mod = this.volcopy.commonData.acp_circ_modifier.filter( + m => m.code() === value)[0]; + return mod ? mod.name() : ''; + + case 'mint_condition': + if (!this.mintConditionYes) { return ''; } + return value === 't' ? + this.mintConditionYes.text : this.mintConditionNo.text; + } - case 'mint_condition': - if (!this.mintConditionYes) { return ''; } - return value === 't' ? - this.mintConditionYes.text : this.mintConditionNo.text; + return value; + } catch(E) { + console.debug(`Invalid value for field ${field}`, E); + return null; } - - return value; } copyWantsChange(copy: IdlObject, field: string, @@ -307,8 +596,8 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { this.values[field] = value; } - if (field === 'owning_lib') { - this.owningLibChanged(value, changeSelection); + if (field === 'owning_lib' || field === 'prefix' || field === 'suffix' || field === 'label_class') { + this.somethingOnCallNumberChanged(field, value, changeSelection); } else { @@ -333,8 +622,10 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { this.emitSaveChange(); } - owningLibChanged(orgId: number, changeSelection?: BatchChangeSelection) { - if (!orgId) { return; } + somethingOnCallNumberChanged(field: string, value: any, changeSelection?: BatchChangeSelection) { + console.debug('somethingOnCallNumberChanged', field, value, changeSelection); + if (!value && (field === 'prefix' || field === 'suffix')) { value = -1; } + if (!value) { return; } // Map existing vol IDs to their replacments. const newVols: any = {}; @@ -342,26 +633,34 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { this.context.copyList().forEach(copy => { if (changeSelection && - !this.copyWantsChange(copy, 'owning_lib', changeSelection)) { + !this.copyWantsChange(copy, field, changeSelection)) { return; } - // Change the copy circ lib to match the new owning lib - // if configured to do so. - if (this.volcopy.defaults.values.circ_lib_mod_with_owning_lib) { - if (copy.circ_lib() !== orgId) { - copy.circ_lib(orgId); - copy.ischanged(true); + if (field === 'owning_lib') { + // Change the copy circ lib to match the new owning lib + // if configured to do so. + if (this.volcopy.defaults?.values.circ_lib_mod_with_owning_lib) { + if (copy.circ_lib() !== value) { + copy.circ_lib(value); + copy.ischanged(true); - this.batchAttrs - .filter(ba => ba.name === 'circ_lib') - .forEach(attr => attr.hasChanged = true); + this.batchAttrs + .filter(ba => ba.name === 'circ_lib') + .forEach(attr => { + attr.hasChanged = true; + attr.checkValuesForCSS(); + }); + } } } const vol = copy.call_number(); - if (vol.owning_lib() === orgId) { return; } // No change needed + if (field === 'owning_lib' && vol.owning_lib() === value) { return; } // No change needed + if (field === 'prefix' && vol.prefix() === value) { return; } // No change needed + if (field === 'suffix' && vol.suffix() === value) { return; } // No change needed + if (field === 'label_class' && vol.label_class() === value) { return; } // No change needed let newVol; if (newVols[vol.id()]) { @@ -373,13 +672,17 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { // will use the existing volume when trying to create a // new volume with the same parameters as an existing volume. newVol = this.idl.clone(vol); - newVol.owning_lib(orgId); + if (field === 'owning_lib') { newVol.owning_lib(value); } + if (field === 'prefix') { newVol.prefix(value); } + if (field === 'suffix') { newVol.suffix(value); } + if (field === 'label_class') { newVol.label_class(value); } newVol.id(this.volcopy.autoId--); newVol.isnew(true); newVols[vol.id()] = newVol; } copy.call_number(newVol); + this.originalVols[newVol.id()] = this.originalVols[vol.id()]; // associate original volume with new volume copy.ischanged(true); this.context.removeCopyNode(copy.id()); @@ -419,7 +722,7 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { this.context.copyList().forEach(copy => { - let entry = copy.stat_cat_entries() + let entry = (copy.stat_cat_entries() || []) .filter(e => e.stat_cat() === catId)[0]; if (clear) { @@ -428,7 +731,7 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { // Removing the entry map (and setting copy.ishanged) is // enough to tell the API to delete it. - copy.stat_cat_entries(copy.stat_cat_entries() + copy.stat_cat_entries( (copy.stat_cat_entries() || []) .filter(e => e.stat_cat() !== catId)); } @@ -444,6 +747,9 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { // Copy has no entry for this stat cat yet. entry = this.idl.create('asce'); entry.stat_cat(catId); + if (!copy.stat_cat_entries()) { + copy.stat_cat_entries([]); + } copy.stat_cat_entries().push(entry); } @@ -457,147 +763,308 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { this.emitSaveChange(); } - openCopyAlerts() { + valueCleared(fieldName: string) { + // Reset all batch attributes + const attr = this.batchAttrs.find(attr => attr.name === fieldName); + attr.hasChanged = false; + attr.editing = false; + attr.checkValuesForCSS(); + // console.debug('attr ' + attr.name, attr); + if (this.context) { + // Restore copies from backup + this.context.copyList().forEach(copy => { + const originalCopy = this.originalCopies[copy.id()]; + if (!originalCopy) { + console.error(`valueCleared, No original state found for copy ${copy.id()}`); + return; + } + + // Determines if copy ischanged() should change + let resetCopyIsChanged = true; + + if (fieldName === 'owning_lib' || fieldName === 'prefix' || fieldName === 'suffix' || fieldName === 'label_class') { + // Special handling + const vol = copy.call_number(); + const origVol = this.originalVols[ vol.id() ]; + if (!origVol) { + console.error(`valueCleared, No original state found for volume ${vol.id()}`); + return; + } + const volValue = this.idl.pkeyValue(vol[fieldName]()); + const origVolValue = this.idl.pkeyValue(origVol[fieldName]()); + // Restore vol field from the original + vol[fieldName](origVolValue); + + // Test to see if vol ischanged() should change + let resetVolIsChanged = true; + this.idl.classes['acn'].fields.forEach(idlField => { + if (idlField.name !== 'call_number') { + if (vol[idlField.name]() !== origVol[idlField.name]()) { + resetVolIsChanged = false; + resetCopyIsChanged = false; + } + } + }); + if (resetVolIsChanged) { + // console.debug(`valueCleared, setting isChanged() to false for volume ${vol.id()}`); + vol.ischanged(false); + } + // console.debug('vol ' + copy.call_number().id(), copy.call_number()); + } else { + const copyValue = this.idl.pkeyValue(copy[fieldName]()); + const origCopyValue = this.idl.pkeyValue(originalCopy[fieldName]()); + // Restore copy field from the original + copy[fieldName](origCopyValue); + } + + this.idl.classes['acp'].fields.forEach(idlField => { + if (idlField.name !== 'call_number' && idlField.name !== 'owning_lib' && idlField.name !== 'prefix' && idlField.name !== 'suffix' && idlField.name !== 'label_class') { + if (copy[idlField.name]() !== originalCopy[idlField.name]()) { + resetCopyIsChanged = false; + } + } + }); + if (resetCopyIsChanged) { + // console.debug(`valueCleared, setting isChanged() to false for copy ${copy.id()}`); + copy.ischanged(false); + } + // console.debug('copy ' + copy.id(), copy); + }); + } + // "new" values new conditions + attr.checkValuesForCSS(); + } + + valueClearedForStatCat(catId: number) { + const attr = this.batchAttrs.find(attr => attr.name === `stat_cat_${catId}`); + if (attr) { + attr.hasChanged = false; + attr.editing = false; + attr.checkValuesForCSS(); + } else { + console.debug(`valueClearedForStatCat, stat_cat_${catId} attr not found`); + } + catId = Number(catId); + // console.debug('valueClearedForStatCat, catId, this.statCatValues', catId, this.statCatValues); + if (this.context) { + // Restore copies from backup + this.context.copyList().forEach(copy => { + // console.debug('valueClearedForStatCat, considering copy.id, copy', copy.id(), copy); + const originalCopy = this.originalCopies[copy.id()]; + const entries = copy.stat_cat_entries(); + const originalEntries = originalCopy.stat_cat_entries(); + + // Find if there's a matching entry in entries + const catIdIndex = entries.findIndex(entry => entry.stat_cat() === catId); + + // Handle the case where originalEntries might be null/undefined + if (!originalEntries) { + // console.debug('valueClearedForStatCat, no original entries, at all'); + // If originalEntries doesn't exist, remove any matching entry from entries + if (catIdIndex !== -1) { + // console.debug('valueClearedForStatCat, removing current entry'); + entries.splice(catIdIndex, 1); + } + } else { + // Look for a matching entry in originalEntries + const orig = originalEntries.find(entry => entry.stat_cat() === catId); + + if (orig) { + // console.debug('valueClearedForStatCat, found original entry'); + // Clone the original matching entry + const clonedOrig = this.idl.clone(orig); + + if (catIdIndex !== -1) { + // Replace the existing entry with the clone + // console.debug('valueClearedForStatCat, replacing current entry with original'); + entries[catIdIndex] = clonedOrig; + } else { + // Append the clone to entries + entries.push(clonedOrig); + // console.debug('valueClearedForStatCat, re-adding original'); + } + } else { + // console.debug('valueClearedForStatCat, original entry not found'); + // No matching original entry, remove from entries if it exists + if (catIdIndex !== -1) { + // console.debug('valueClearedForStatCat, deleting current entry'); + entries.splice(catIdIndex, 1); + } + } + } + + // Do we need to change copy .isChanged()? + if (copy.call_number().ischanged()) { + let resetCopyIsChanged = true; + this.idl.classes['acp'].fields.forEach(idlField => { + if (idlField.name !== 'call_number' && idlField.name !== 'owning_lib' && idlField.name !== 'prefix' && idlField.name !== 'suffix' && idlField.name !== 'label_class') { + if (copy[idlField.name]() !== originalCopy[idlField.name]()) { + resetCopyIsChanged = false; + } + } + }); + if (resetCopyIsChanged) { + // console.debug(`valueClearedForStatCat, setting isChanged() to false for copy ${copy.id()}`); + copy.ischanged(false); + } + } + }); + } + // console.debug('valueClearedForStatCat, deleting catId from this.statCatValues'); + delete this.statCatValues[catId]; + if (attr) { + attr.checkValuesForCSS(); + } + } + + hasClearedAlerts(): boolean { + return this.context.changedAlerts?.some(a => a.ack_time()); + } + + getClearedAlertCount(): number { + return this.context.changedAlerts?.filter(a => a.ack_time()).length || 0; + } + + initCopyAlerts() { + // console.debug('CopyAlertsDialog, initCopyAlerts(), this.copyAlertsDialog', this.copyAlertsDialog); + if (!this.copyAlertsDialog) {return;} + + // The dialog is already persistent on the template + this.copyAlertsDialog.copies = this.context.copyList(); + this.copyAlertsDialog.copyIds = []; this.copyAlertsDialog.inPlaceCreateMode = true; - this.copyAlertsDialog.copyIds = this.context.copyList().map(c => c.id()); + this.copyAlertsDialog.templateOnlyMode = this.templateOnlyMode; + // console.debug('templateOnlyMode', this.copyAlertsDialog.templateOnlyMode); + + // Pre-populate any existing changes + this.copyAlertsDialog.newThings = this.context.newAlerts.map( n => this.idl.clone(n) ) as ICopyAlert[]; + this.copyAlertsDialog.changedThings = this.context.changedAlerts.map( c => this.idl.clone(c) ) as ICopyAlert[]; + this.copyAlertsDialog.deletedThings = this.context.deletedAlerts.map( d => this.idl.clone(d) ) as ICopyAlert[]; + this.copyAlertsDialog.initialize(); + } + + openCopyAlerts($event) { + $event.preventDefault(); + $event.stopPropagation(); + this.initCopyAlerts(); this.copyAlertsDialog.open({size: 'lg'}).subscribe(changes => { if (!changes) { return; } - if ((!changes.newAlerts || changes.newAlerts.length === 0) && - (!changes.changedAlerts || changes.changedAlerts.length === 0) - ) { - return; - } + const { newThings, changedThings, deletedThings } = changes; + // console.debug('CopyAlertsDialog, openCopyAlerts(), changes', changes); - if (changes.newAlerts || changes.changedAlerts) { - this.emitSaveChange(); - } + this.context.newAlerts = newThings; + this.context.changedAlerts = changedThings; + this.context.deletedAlerts = deletedThings; + + // console.debug('CopyAlertsDialog, openCopyAlerts(), copy before updateInMemory', this.idl.clone(this.context.copyList()[0])); + this.context.updateInMemoryCopiesWithAlerts(); + // console.debug('CopyAlertsDialog, openCopyAlerts(), copy after updateInMemory', this.idl.clone(this.context.copyList()[0])); + this.emitSaveChange(); - if (changes.newAlerts) { - this.context.copyList().forEach(copy => { - changes.newAlerts.forEach(newAlert => { - const a = this.idl.clone(newAlert); - a.isnew(true); - a.copy(copy.id()); - if (!copy.copy_alerts()) { copy.copy_alerts([]); } - copy.copy_alerts().push(a); - copy.ischanged(true); - }); - }); - } - if (changes.changedAlerts) { - this.context.copyList().forEach(copy => { - changes.changedAlerts.forEach(alert => { - const matching = copy.copy_alerts().filter(a => a.id() === alert.id()); - matching.forEach( existing => { - existing.ischanged(true); - existing.alert_type(alert.alert_type()); - existing.temp(alert.temp()); - existing.ack_time(alert.ack_time()); - existing.ack_staff(alert.ack_staff()); - existing.note(alert.note()); - copy.ischanged(true); - }); - }); - }); - } }); } - openCopyTags() { + initCopyTags() { + // console.debug('CopyTagsDialog, initCopyTags(), this.copyTagsDialog', this.copyTagsDialog); + if (!this.copyTagsDialog) {return;} + + // The dialog is already persistent on the template + this.copyTagsDialog.copies = this.context.copyList(); + this.copyTagsDialog.copyIds = []; this.copyTagsDialog.inPlaceCreateMode = true; - this.copyTagsDialog.copyIds = this.context.copyList().map(c => c.id()); + this.copyTagsDialog.templateOnlyMode = this.templateOnlyMode; + // console.debug('templateOnlyMode', this.copyTagsDialog.templateOnlyMode); - this.copyTagsDialog.open({size: 'lg'}).subscribe(changes => { - if ((!changes.newTags || changes.newTags.length === 0) && - (!changes.deletedMaps || changes.deletedMaps.length === 0)) { - return; - } + // Pre-populate any existing changes + this.copyTagsDialog.newThings = this.context.newTagMaps as ICopyTagMap[]; + this.copyTagsDialog.changedThings = this.context.changedTagMaps as ICopyTagMap[]; + this.copyTagsDialog.deletedThings = this.context.deletedTagMaps as ICopyTagMap[]; - changes.newTags.forEach(tag => { - this.context.copyList().forEach(copy => { + this.copyTagsDialog.initialize(); + } - if (copy.tags().filter( - m => m.tag() === tag.id()).length > 0) { - return; // map already exists - } + openCopyTags($event) { + $event.preventDefault(); + $event.stopPropagation(); + this.initCopyTags(); + this.copyTagsDialog.open({size: 'lg'}).subscribe(changes => { + if (!changes) { return; } - const map = this.idl.create('acptcm'); - map.isnew(true); - map.copy(copy.id()); - map.tag(tag); + const { newThings, changedThings, deletedThings } = changes; - copy.tags().push(map); - copy.ischanged(true); - }); - }); + this.context.newTagMaps = newThings; + this.context.changedTagMaps = changedThings; + this.context.deletedTagMaps = deletedThings; + + this.context.updateInMemoryCopiesWithTags(); + this.emitSaveChange(); - if (this.context.copyList().length === 1) { - const copy = this.context.copyList()[0]; - changes.deletedMaps.forEach(tag => { - const existing = copy.tags().filter(t => t.id() === tag.id())[0]; - if (existing) { - existing.isdeleted(true); - copy.ischanged(true); - } - }); - } }); } - openCopyNotes() { + initCopyNotes() { + // console.debug('CopyNotesDialog, initCopyNotes(), this.copyNotesDialog', this.copyNotesDialog); + if (!this.copyNotesDialog) {return;} + + // The dialog is already persistent on the template + this.copyNotesDialog.copies = this.context.copyList(); + this.copyNotesDialog.copyIds = []; this.copyNotesDialog.inPlaceCreateMode = true; - this.copyNotesDialog.copyIds = this.context.copyList().map(c => c.id()); + this.copyNotesDialog.templateOnlyMode = this.templateOnlyMode; + // console.debug('templateOnlyMode', this.copyNotesDialog.templateOnlyMode); + + // Pre-populate any existing changes + this.copyNotesDialog.newThings = this.context.newNotes as ICopyNote[]; + this.copyNotesDialog.changedThings = this.context.changedNotes as ICopyNote[]; + this.copyNotesDialog.deletedThings = this.context.deletedNotes as ICopyNote[]; + this.copyNotesDialog.initialize(); + } + + openCopyNotes($event) { + $event.preventDefault(); + $event.stopPropagation(); + this.initCopyNotes(); this.copyNotesDialog.open({size: 'lg'}).subscribe(changes => { if (!changes) { return; } - if ((!changes.newNotes || changes.newNotes.length === 0) && - (!changes.delNotes || changes.delNotes.length === 0) - ) { - return; - } + const { newThings, changedThings, deletedThings } = changes; + + this.context.newNotes = newThings; + this.context.deletedNotes = deletedThings; + this.context.changedNotes = changedThings; + + this.context.updateInMemoryCopiesWithNotes(); + this.emitSaveChange(); - changes.newNotes.forEach(note => { - this.context.copyList().forEach(copy => { - const n = this.idl.clone(note); - n.owning_copy(copy.id()); - copy.notes().push(n); - copy.ischanged(true); - }); - }); - if (this.context.copyList().length === 1) { - const copy = this.context.copyList()[0]; - changes.delNotes.forEach(note => { - const existing = copy.notes().filter(n => n.id() === note.id())[0]; - if (existing) { - existing.isdeleted(true); - copy.ischanged(true); - } - }); - } }); } - applyTemplate() { - const entry = this.copyTemplateCbox.selected; + async applyTemplate(providedTemplate?: any): Promise<void> { + const entry = providedTemplate || this.copyTemplateCbox.selected; + // console.debug('applyTemplate, entry', entry); if (!entry) { return; } - this.store.setLocalItem('cat.copy.last_template', entry.id); + this.saveTemplateCboxSelection(entry); const template = this.volcopy.templates[entry.id]; + // console.debug('applyTemplate, template', template); Object.keys(template).forEach(field => { const value = template[field]; + // console.debug('applyTemplate, field, value', field, value); if (value === null || value === undefined) { return; } if (field === 'status' && this.volcopy.copyStatIsMagic(value)) { return; } - // Call number 'value' is a nested object with call number- - // specific key-value pairs. - // NOTE: Changed values are visible in the interface, but - // they are not highlighted the way copy attribute changes are. + // Call number 'value' was nested object with call number- + // specific key-value pairs. This is being supplanted with + // prefix, suffix, and label_class as sibling attributes with + // the copy fields, and they may now be updated independently. + // Resaving such an applied template will remove the nested + // structure. if (field === 'callnumber') { // Currently supported fields are prefix, suffix, and // classification (label_class). These all use numeric @@ -616,12 +1083,25 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { field = 'label_class'; } + let changeMade = false; this.context.volNodes().forEach(volNode => { if (Number(volNode.target[field]())) { - volNode.target[field](newVal); - volNode.target.ischanged(changedFields); + if (newVal !== volNode.target[field]()) { + volNode.target[field](newVal); + volNode.target.ischanged(true); + volNode.target.ischanged(changedFields); + changeMade = true; + } } }); + if (changeMade) { + this.batchAttrs + .filter(ba => ba.name === field) + .forEach(attr => { + attr.hasChanged = true; + attr.checkValuesForCSS(); + }); + } }); } @@ -635,7 +1115,10 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { const attr = this.batchAttrs.find(attr => attr.name?.split('_').pop() === catId ); - if (attr) { attr.hasChanged = true; } + if (attr) { + attr.hasChanged = true; + attr.checkValuesForCSS(); + } } }); return; @@ -645,37 +1128,153 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { // Templates can be used to create alerts, but not edit them. if (field === 'copy_alerts' && Array.isArray(value)) { value.forEach(a => { - this.context.copyList().forEach(copy => { - // Check for existing alert, don't apply duplicates - let dupskip = 0; - copy.copy_alerts().forEach(curAlert => { - if(a.alert_type === curAlert.alert_type() && - a.temp === curAlert.temp() && - a.note === curAlert.note() ) { - console.log('Already have this alert',a); // identical alert exists. - dupskip = 1; + // Check for existing alert, don't apply duplicates + let dupskip = 0; + this.context.newAlerts.forEach(curAlert => { + if(a.alert_type === curAlert.alert_type() && + a.temp === curAlert.temp() && + a.note === curAlert.note() ) { + const dup_msg = $localize`Already have this alert`; + console.warn(dup_msg, a); + this.toast.warning(dup_msg); + dupskip = 1; + } + }); + if(dupskip) {return;} // skip this alert + + const newAlert = this.idl.create('aca'); + newAlert.id(this.volcopy.autoId--); + newAlert.isnew(true); + newAlert.alert_type(a.alert_type); + if (this.copyAlertsDialog.disabledAlertTypes.includes(a.alert_type)) { + const inactive_msg = $localize`Alert using an inactive alert type. Template needs updating.`; + console.warn(inactive_msg, a); + this.toast.warning(inactive_msg); + } + newAlert.temp(a.temp); + newAlert.note(a.note); + newAlert.create_staff(this.auth.user().id()); + newAlert.create_time('now'); + + this.context.newAlerts.push(newAlert); // for our pending display + }); + this.context.updateInMemoryCopiesWithAlerts(); + + return; + } + + // Since there should be no extant tag templates in the wild (none would be working), + // we'll go ahead and change the representation here. We'll match on the ID here, but + // include other tag information for troubleshooting. Less portable, but the precedent + // has already been set, and this dissolves the problem of matching, which would + // involve org units and tag types, which are already not portable. + + if (field === 'tags' && Array.isArray(value)) { + value.forEach(async (a) => { + let actualTag = null; + if (! ('id' in a)) { + const err_msg = $localize`Tag in template missing id field`; + console.error(err_msg, a); + this.toast.danger(err_msg); + return; + } else { + try { + const flesh = { + flesh: 1, + flesh_fields: { + acpt: ['tag_type'] + } + }; + // TODO: caching + actualTag = await firstValueFrom(this.pcrud.retrieve('acpt', a.id, flesh)); + // console.debug('actualTag', actualTag); + if (!actualTag) { + const err_msg = $localize`Tag in template not found`; + console.error(err_msg, a); + this.toast.danger(err_msg); + return; } - }); - if(dupskip) {return;} // skip copy for this new alert - - const newAlert = this.idl.create('aca'); - newAlert.isnew(true); - newAlert.copy(copy.id()); - newAlert.alert_type(a.alert_type); - newAlert.temp(a.temp); - newAlert.note(a.note); - newAlert.create_staff(this.auth.user().id()); - newAlert.create_time('now'); - - if (Array.isArray(copy.copy_alerts())) { - copy.copy_alerts().push(newAlert); - } else { - copy.copy_alerts([newAlert]); + } catch(E) { + const err_msg = $localize`Error retrieving tag from template`; + console.error(err_msg, E); + this.toast.danger(err_msg); + return; } + } + // Check for existing alert, don't apply duplicates + let dupskip = 0; + this.context.newTagMaps.forEach(curTagMap => { + const curTag = curTagMap.tag(); + if(a.id === this.idl.pkeyValue( curTag ) ) { + const dup_msg = $localize`Already have a tagmap pointing to this tag`; + console.warn(dup_msg,a); + this.toast.warning(dup_msg); + dupskip = 1; + } + }); + if(dupskip) {return;} // skip this tag map + + // no longer vivicating tags here, just tag maps + const newTagMap = this.idl.create('acptcm'); + newTagMap.id(this.volcopy.autoId--); + newTagMap.isnew(true); + const tag = actualTag || this.idl.fromHash(a, 'acpt') ; + if (typeof tag.id !== 'function') { + // console.debug('tag from hash', tag); + const err_msg = $localize`Invalid tag found in template.`; + console.error(err_msg); + this.toast.danger(err_msg); + return; + } + newTagMap.tag( tag ); + const tag_type = typeof tag.tag_type().code !== 'function' + ? this.idl.fromHash( tag.tag_type(), 'cctt' ) + : tag.tag_type(); + if (typeof tag_type.code !== 'function') { + // console.debug('tag_type from hash', tag_type); + const err_msg = $localize`Invalid tag type found in template.`; + console.error(err_msg); + this.toast.danger(err_msg); + return; + } + newTagMap.tag().tag_type( tag_type ); - copy.ischanged(true); + this.context.newTagMaps.push( newTagMap ); // for our pending display + console.debug('applying tags...', this); + }); + this.context.updateInMemoryCopiesWithTags(); + + return; + } + + // Copy notes are stored as hashes of the bits we need. + // Templates can be used to create notes, but not edit them. + if (field === 'notes' && Array.isArray(value)) { + value.forEach(a => { + // Check for existing alert, don't apply duplicates + let dupskip = 0; + this.context.newNotes.forEach(curNote => { + if(a.pub === curNote.pub() && + a.title === curNote.title() && + a.value === curNote.value() ) { + const dup_msg = $localize`Already have this note`; + console.warn(dup_msg, a); + this.toast.warning(dup_msg); + dupskip = 1; + } }); + if(dupskip) {return;} // skip this note + + const newNote = this.idl.create('acpn'); + newNote.id(this.volcopy.autoId--); + newNote.isnew(true); + newNote.pub(a.pub); + newNote.title(a.title); + newNote.value(a.value); + + this.context.newNotes.push( newNote ); // for our pending display }); + this.context.updateInMemoryCopiesWithNotes(); return; } @@ -684,38 +1283,50 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { // the local code assumes copy field is fleshed. let promise = Promise.resolve(value); - if (field === 'location') { + if (field === 'location' && value !== null) { // May be a 'remote' location. Fetch as needed. - promise = this.volcopy.getLocation(value); + promise = this.volcopy.getLocation(Number(value)); } promise.then(val => { + if (value !== null && val === null) { + console.debug(`broken value for field ${field}`, value); + } this.applyCopyValue(field, val); // Indicate in the form these values have changed this.batchAttrs .filter(ba => ba.name === field) - .forEach(attr => attr.hasChanged = true); + .forEach(attr => { + attr.hasChanged = true; + attr.checkValuesForCSS(); + }); }); }); } - saveTemplate() { - const entry: ComboboxEntry = this.copyTemplateCbox.selected; - if (!entry) { return; } + saveTemplate(isnew: boolean) { let name; let template; - - if (entry.freetext) { - name = entry.label; - // freetext entries don't have an ID, but we may need one later. - entry.id = entry.label; - template = {}; + let entry = this.copyTemplateCbox.selected; + if (isnew || !entry) { + if (entry) { + name = this.copyTemplateCbox.selected.label; + } else { + name = window.prompt($localize`Enter name for template`); + } + if (!name) { return; } + if (this.volcopy.templateNames.map(t => t.label).includes(name)) { + window.alert($localize`There is already a template with this name; not saved.`); + return; + } + entry = {label: name, id: name, freetext: false}; } else { name = entry.id; - template = this.volcopy.templates[name]; } + // eslint-disable-next-line prefer-const + template = {}; // never additive this.batchAttrs.forEach(comp => { if (!comp.hasChanged) { return; } @@ -723,6 +1334,8 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { const field = comp.name; const value = this.values[field]; + console.debug('Building template: found field, value', field, value); + if (value === null) { delete template[field]; return; @@ -741,12 +1354,14 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { template[field] = typeof value === 'object' ? value.id() : value; } + console.debug('Building template: set field, value', field, template[field]); }); // Volume attributes that are stored in the template. // prefix, suffix and Classification // Do we actually want to loop through all volumes for this? - this.context.volNodes().forEach(volNode => { + // No, but this is going the way of the dodo with top-level prefix, suffix, and label_class + /* this.context.volNodes().forEach(volNode => { const vol = volNode.target; if(vol.ischanged()){ // Something was changed template.callnumber = {}; @@ -764,58 +1379,93 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { // volNode.target.forEach(field=>{ // console.log("Saving Volume info to template",field); // }); - }); - - this.volcopy.templates[name] = template; - this.volcopy.saveTemplates().then(x => { - this.savedHoldingsTemplates.current().then(str => this.toast.success(str)); - if (entry.freetext) { - // once a new template has been added, make it - // display like any other in the comobox - this.copyTemplateCbox.selected = - this.volcopy.templateNames.filter(_ => _.label === name)[0]; - } - }); - } - - exportTemplate($event) { - if (this.fileExport.inProgress()) { return; } - - this.fileExport.exportFile( - $event, JSON.stringify(this.volcopy.templates), 'text/json'); - } + });*/ + + // alerts, tags, and notes + const newAlerts = this.volcopy.currentContext.newAlerts || []; + console.debug('Building template: found copy alerts',newAlerts); + template.copy_alerts = []; + newAlerts.forEach( n_alert => { + const t_alert = { + 'temp': n_alert.temp(), + 'alert_type': n_alert.alert_type(), + 'note' : n_alert.note() + }; + console.debug('Building template: pushing t_alert',t_alert); + template.copy_alerts.push(t_alert); + } ); + if (!template.copy_alerts.length) { + delete template.copy_alerts; + } - importTemplate($event) { - const file: File = $event.target.files[0]; - if (!file) { return; } + const newTagMaps = this.volcopy.currentContext.newTagMaps || []; + console.debug('Building template: found copy tags',newTagMaps); + template.tags = []; + newTagMaps.forEach( n_tagmap => { + // See tag comments under applyTemplates + console.log('n_tagmap', n_tagmap); + const t_tag = { + 'id': n_tagmap.tag().id(), // the only match point, the rest is for troubleshooting <- not actually true at the moment + 'pub': n_tagmap.tag().pub(), + 'tag_type': this.idl.toHash( n_tagmap.tag().tag_type() ), + 'label': n_tagmap.tag().label(), + 'value': n_tagmap.tag().value(), + 'staff_note' : n_tagmap.tag().staff_note() || '' + }; + console.debug('Building template: pushing t_tag',t_tag); + template.tags.push(t_tag); + } ); + if (!template.tags.length) { + delete template.tags; + } - const reader = new FileReader(); + const newNotes = this.volcopy.currentContext.newNotes || []; + console.debug('Building template: found copy notes',newNotes); + template.notes = []; + newNotes.forEach( n_note => { + const t_note = { + 'pub': n_note.pub(), + 'title': n_note.title(), + 'value' : n_note.value() + }; + console.debug('Building template: pushing t_note',t_note); + template.notes.push(t_note); + } ); + if (!template.notes.length) { + delete template.notes; + } - reader.addEventListener('load', () => { + // wrap it up + console.debug('Building template: all together',template); - try { - const template = JSON.parse(reader.result as string); - const theKeys = Object.keys(template); - for(let i = 0; i < theKeys.length; i++){ - const name = theKeys[i]; - this.volcopy.templates[name]=template[name]; + let confirmed = true; + if (! Object.keys(template).length) { + confirmed = window.confirm($localize`This would save as an empty template. Are you sure?`); + } + if (confirmed) { + this.volcopy.templates[name] = template; + this.volcopy.saveTemplates().then(x => { + this.savedHoldingsTemplates.current().then(str => this.toast.success(str + ' ' + name)); + if (isnew) { + // give combobox a lil' shove + this.copyTemplateCbox.entrylist.unshift(entry); } - } catch (E) { - console.error('Invalid Item Attribute template', E); - return; - } + this.saveTemplateCboxSelection(entry); + }); + } + } - this.volcopy.saveTemplates(); - // Adds the new one to the list and re-sorts the labels. - this.volcopy.fetchTemplates(); - }); + exportTemplate($event) { + return this.volcopy.exportTemplate($event, false); + } - reader.readAsText(file); + importTemplate($event) { + return this.volcopy.importTemplate($event); } // Returns null when no export is in progress. exportTemplateUrl(): SafeUrl { - return this.fileExport.safeUrl; + return this.volcopy.exportTemplateUrl(); } deleteTemplate() { @@ -823,16 +1473,27 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { if (!entry) { return; } delete this.volcopy.templates[entry.id]; this.volcopy.saveTemplates().then( - x => this.deletedHoldingsTemplate.current().then(str => this.toast.success(str)) + x => this.deletedHoldingsTemplate.current().then(str => this.toast.success(str + ' ' + name)) ); - this.copyTemplateCbox.selected = null; + let newId; // prevent null / undefined from causing an expression checked error + this.copyTemplateCbox.selected = newId; + this.saveTemplateCboxSelection({id: newId}); } displayAttr(field: string): boolean { - return this.volcopy.defaults.hidden[field] !== true; + // show everything for templateOnlyMode + return this.templateOnlyMode || this.volcopy.defaults?.hidden?.[field] !== true; } - copyFieldLabel(field: string): string { + fieldLabel(field: string): string { + + // handle non-copy fields first + switch(field) { + case 'label_class': return $localize`Call Number Label Classification`; + case 'prefix': return $localize`Call Number Prefix`; + case 'suffix': return $localize`Call Number Suffix`; + } + const def = this.idl.classes.acp.field_map[field]; return def ? def.label : ''; } @@ -852,9 +1513,14 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { // save-ability of the form. emitSaveChange() { setTimeout(() => { + // console.debug('CopyAttrsComponent, emitSaveChange()'); const canSave = this.batchAttrs.filter( - attr => attr.warnOnRequired()).length === 0; - + attr => { + const w= attr.warnOnRequired(); + // console.debug('attr.warnOnRequired()', attr, w); + return w; + }).length === 0; + // console.debug('CopyAttrsComponent, emitSaveChange(), canSave', canSave); this.canSaveChange.emit(canSave); }); } @@ -865,7 +1531,30 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { return this.batchAttrs.filter(attr => attr.editing).length > 0; } + onKeydown(field: string, $event) { + switch ($event.key) { + case 'Escape': + // console.debug('Canceling out of', field); + this.cancel(field); + break; + case 'Enter': + // console.debug('Saving', field); + this.save(field); + break; + } + } + + save(field) { + // console.debug('save() on Enter:', field, this.values[field]); + this.batchAttrs.filter(attr => attr.editing && attr.name === field).forEach(attr => attr.save()); + } + + cancel(field) { + this.batchAttrs.filter(attr => attr.editing && attr.name === field).forEach(attr => attr.cancel()); + } + applyPendingChanges() { + // console.debug('applyPendingChanges()'); // If a user has left any changes in the 'editing' state, this // will go through and apply the values so they do not have to // click Apply for every one. @@ -873,16 +1562,130 @@ export class CopyAttrsComponent implements OnInit, OnDestroy, AfterViewInit { } copyLocationOrgs(): number[] { - if (!this.context) { return []; } + if (!this.context) { console.debug('No copy location context'); return []; } // Make sure every org unit represented by the edit batch // is represented. - const ids = this.context.orgNodes().map(n => n.target.id()); + const ids = this.context.orgNodes()?.map(n => n.target.id()) || []; // Make sure all locations within the "full path" of our // workstation org unit are included. return ids.concat(this.org.fullPath(this.auth.user().ws_ou())); } + + alertsHaveChanged() { + return this.context?.newAlerts?.length + || this.context?.changedAlerts?.length + || this.context?.deletedAlerts?.length; + } + + tagsHaveChanged() { + return this.context?.newTagMaps?.length + || this.context?.changedTagMaps?.length + || this.context?.deletedTagMaps?.length; + } + + notesHaveChanged() { + return this.context?.newNotes?.length + || this.context?.changedNotes?.length + || this.context?.deletedNotes?.length; + } + + clearChangesAction() { + // console.debug('clearChangesAction()'); + // Reset all batch attributes + this.batchAttrs.forEach(attr => { + attr.hasChanged = false; + attr.editing = false; + attr.checkValuesForCSS(); + // console.debug('attr ' + attr.name, attr); + }); + + // Clear all values + this.values = {}; + this.statCatValues = {}; + + if (this.copyAlertsDialog) { + this.copyAlertsDialog.clearPending(); + this.context.newAlerts = []; // not sure why the binding isn't behaving bidirectionally + this.context.changedAlerts = []; + this.context.deletedAlerts = []; + } + if (this.copyTagsDialog) { + this.copyTagsDialog.clearPending(); + this.context.newTagMaps = []; // not sure why the binding isn't behaving bidirectionally + this.context.changedTagMaps = []; + this.context.deletedTagMaps = []; + } + if (this.copyNotesDialog) { + this.copyNotesDialog.clearPending(); + this.context.newNotes = []; // not sure why the binding isn't behaving bidirectionally + this.context.changedNotes = []; + this.context.deletedNotes = []; + } + + if (this.context) { + // Restore copies from backup + this.context.copyList().forEach(copy => { + const originalCopy = this.originalCopies[copy.id()]; + if (!originalCopy) { + // console.debug(`No original state found for copy ${copy.id()}`); + this.originalCopies[copy.id()] = this.idl.create('acp'); + this.originalCopies[copy.id()].id( copy.id() ); + } + + // Restore each field from the original + this.idl.classes['acp'].fields.forEach(field => { + if (field.name !== 'call_number') { + copy[field.name](originalCopy[field.name]()); + } + }); + + copy.ischanged(false); + // console.debug('copy ' + copy.id(), copy); + }); + + // Restore volumes from backup + this.context.volNodes().forEach(volNode => { + const originalVol = this.originalVols[volNode.target.id()]; + if (!originalVol) { + // console.debug(`No original state found for volume ${volNode.target.id()}`); + this.originalVols[volNode.target.id()] = this.idl.create('acn'); + this.originalVols[volNode.target.id()].id( volNode.target.id() ); + } + this.idl.classes['acn'].fields.forEach(field => { + volNode.target[field.name](originalVol[field.name]()); + }); + volNode.target.ischanged(false); + // console.debug('volNode ' + volNode.target.id(), volNode); + }); + + // "new" values new conditions + this.batchAttrs.forEach(attr => { + attr.checkValuesForCSS(); + }); + } + + this.clearChanges.emit(null); + } + + hasDisabledAlerts(alerts: any[]): boolean { + return alerts?.some(alert => this.copyAlertsDialog && this.copyAlertsDialog?.disabledAlertTypes?.includes(alert.alert_type())); + } + + countTotalTags(): number { + if (!this.copyTagsDialog) {return 0;} + let existing = []; + + if (this.context.copyList().length > 1 ) { + existing = this.copyTagsDialog.allTagsInCommon; + } else { + existing = this.context.copyList()[0].tags(); + } + + // console.debug('countTotalTags: ', existing.length, this.context.newTagMaps.length, this.context.deletedTagMaps.length); + return existing.length + this.context.newTagMaps.length - this.context.deletedTagMaps.length; + } } diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/routing.module.ts index 3243fd5249..e0d664c960 100644 --- a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/routing.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/routing.module.ts @@ -1,9 +1,23 @@ import {NgModule} from '@angular/core'; import {RouterModule, Routes} from '@angular/router'; +import {VolCopyTemplateGridComponent} from './template-grid.component'; +import {VolCopyTemplateEditComponent} from './template-edit.component'; import {VolCopyComponent} from './volcopy.component'; import {CanDeactivateGuard} from '@eg/share/util/can-deactivate.guard'; const routes: Routes = [{ + path: 'template_grid', + component: VolCopyTemplateGridComponent, + canDeactivate: [CanDeactivateGuard] +},{ + path: 'template', + component: VolCopyTemplateEditComponent, + canDeactivate: [CanDeactivateGuard] +},{ + path: 'template/:target', + component: VolCopyTemplateEditComponent, + canDeactivate: [CanDeactivateGuard] +},{ path: ':tab/:target/:target_id', component: VolCopyComponent, canDeactivate: [CanDeactivateGuard] diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/template-edit.component.html b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/template-edit.component.html new file mode 100644 index 0000000000..28868438b3 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/template-edit.component.html @@ -0,0 +1,5 @@ +<eg-staff-banner bannerText="Item Attributes Template Editor" i18n-bannerText> +</eg-staff-banner> + +<eg-copy-attrs [context]="context" [contextChanged]="contextChanged" #copyAttrs + [templateOnlyMode]="true" [template]="target" (canSaveChange)="attrsCanSaveChange($event)"></eg-copy-attrs> diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/template-edit.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/template-edit.component.ts new file mode 100644 index 0000000000..1e4267c77c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/template-edit.component.ts @@ -0,0 +1,170 @@ +/* eslint-disable max-len, no-prototype-builtins */ +import {Component, Input, OnInit, OnDestroy, ViewChild} from '@angular/core'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {BehaviorSubject, Subject, Observable, of, from} from 'rxjs'; +import {filter,take,tap,map,takeUntil} from 'rxjs/operators'; +import {IdlObject, IdlService} from '@eg/core/idl.service'; +import {OrgService} from '@eg/core/org.service'; +import {AuthService} from '@eg/core/auth.service'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {GridDataSource, GridCellTextGenerator} from '@eg/share/grid/grid'; +import {GridFlatDataService} from '@eg/share/grid/grid-flat-data.service'; +import {Pager} from '@eg/share/util/pager'; +import {VolCopyContext} from './volcopy'; +import {VolCopyService} from './volcopy.service'; +import {VolCopyComponent} from './volcopy.component'; +import {CopyAttrsComponent} from './copy-attrs.component'; + +@Component({ + selector: 'eg-volcopy-template-edit', + templateUrl: 'template-edit.component.html', + styles: ['::ng-deep body:has(eg-volcopy-template-edit) { background-color: var(--bs-body-bg-highlight) }'] +}) +export class VolCopyTemplateEditComponent implements OnInit, OnDestroy { + + private destroy$ = new Subject<void>(); + + target: string = null; // id for edit, null for new + newTemplate = true; + + context: VolCopyContext; + private contextChange = new BehaviorSubject<VolCopyContext>(null); + // or this.context instead of null, but subscribers will get the broadcast during init + contextChanged = this.contextChange.asObservable(); + + @ViewChild('copyAttrs', {static: false}) copyAttrs: CopyAttrsComponent; + + loading = true; + + constructor( + private router: Router, + private route: ActivatedRoute, + private idl: IdlService, + private org: OrgService, + private auth: AuthService, + public volcopy: VolCopyService + ) {} + + ngOnInit() { + // console.debug('VolCopyTemplateEditComponent, ngOnInit, this', this); + + this.initVolCopyService().then( () => { + + // console.debug('VolCopyTemplateEditComponent, VolCopyService initialized'); + this.loading = false; + this.createStubs(); + + this.route.paramMap.pipe( + takeUntil(this.destroy$) + ).subscribe( + (params: ParamMap) => { + this.negotiateRoute(params); + if (this.target) { + this.autoApplyTargetTemplate(); + } else { + // ensure no template selected in combobox + this.copyAttrs.saveTemplateCboxSelection(null); + } + this.contextChange.next(this.context); // tickles CopyAttrsComponent + } + ); + }); + this.volcopy.templatesRefreshed$.pipe( + takeUntil(this.destroy$) + ).subscribe(() => { + // console.debug('VolCopyTemplateEditComponent, noticed templatesRefreshed$'); + // If we're editing an existing template, check if it still exists + if (this.target && !this.volcopy.templates.hasOwnProperty(this.target)) { + console.warn('Template being edited deleted elsewhere'); + } + }); + } + + autoApplyTargetTemplate() { + // console.debug('VolCopyTemplateEditComponent, autoApplyTargetTemplate, setting up subscription'); + if (this.copyAttrs) { + this.copyAttrs.initialized$.pipe( + filter(initialized => initialized), // shorthand for filter on true + take(1) + ).subscribe(() => { + // console.debug('VolCopyTemplateEditComponent, calling copyAttrs.applyTemplate()'); + this.copyAttrs.applyTemplate( { id: this.target, label: this.target } ); // my original quick and dirty idea I should have tried first + }); + } else { + console.error('VolCopyTemplateEditComponent, autoApplyTargetTemplate, copyAttrs not ready'); + } + } + + initVolCopyService(): Promise<any> { + // console.debug('VolCopyTemplateEditComponent, initVolCopyService'); + if (this.volcopy.currentContext) { + // Avoid clobbering the context on route change. + this.context = this.volcopy.currentContext; + // console.debug('VolCopyTemplateEditComponent, reusing currentContext'); + } else { + this.context = new VolCopyContext(); + this.context.org = this.org; // inject; + this.context.idl = this.idl; // inject; + // console.debug('VolCopyTemplateEditComponent, new context'); + } + + if (this.volcopy.currentContext) { + return Promise.resolve(); + } else { + // Avoid refetching the data during route changes. + this.volcopy.currentContext = this.context; + return this.volcopy.load(); // returns a promise, not an observable + } + } + + negotiateRoute(params: ParamMap) { + // console.debug('VolCopyTemplateEditComponent, negotiateRoute', params); + const encodedTarget = params.get('target'); + this.target = encodedTarget ? decodeURIComponent(atob(encodedTarget)) : null; + + if (this.target) { + this.newTemplate = false; + } + + if (this.target) { + // console.debug('VolCopyTemplateEditComponent, target', this.target); + // console.debug('VolCopyTemplateEditComponent, templates we are checking against', this.volcopy.templates); + if (!this.volcopy.templates.hasOwnProperty(this.target)) { + console.warn('VolCopyTemplateEditComponent, template not found, using as default name for a new one'); + } else { + console.debug('VolCopyTemplateEditComponent, found template', this.target); + } + } else { + console.debug('VolCopyTemplateEditComponent, new template'); + } + } + + createStubs() { + // console.debug('VolCopyTemplateEditComponent, creating stubs'); + const vol = this.volcopy.createStubVol( -1, this.auth.user().ws_ou() ); + this.idl.classes['acn'].fields.forEach( field => { + if (field.name !== 'id' && field.name !== 'owning_lib') { + vol[field.name](null); + } + }); + const item = this.volcopy.createStubCopy(vol); + this.idl.classes['acp'].fields.forEach( field => { + if (field.name !== 'id' && field.name !== 'call_number' && field.name !== 'copy_alerts' && field.name !== 'tags' && field.name !== 'notes') { + item[field.name](null); + } + }); + // item.call_number().label(''); + this.context.findOrCreateCopyNode( item ); + } + + attrsCanSaveChange($event) { + console.debug('VolCopyTemplateEditComponent, attrsCanSaveChange', $event); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/template-grid.component.html b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/template-grid.component.html new file mode 100644 index 0000000000..2783a47522 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/template-grid.component.html @@ -0,0 +1,155 @@ +<eg-alert-dialog #importSummaryDialog + i18n-dialogTitle dialogTitle="Template Import Summary" + [dialogBodyTemplate]="importResultsTemplate"> +</eg-alert-dialog> + +<ng-template #importResultsTemplate> + <ng-container *ngFor="let result of importResults"> + <h3 class="border-bottom">{{result.section}}</h3> + <ol> + <li *ngFor="let item of result.items" class="ps-1">{{item}}</li> + </ol> + </ng-container> +</ng-template> + +<!-- when displayed as a standalone page --> +<ng-container *ngIf="route.snapshot.url.join('/') === 'template_grid'"> + <eg-staff-banner bannerText="Holdings Templates" i18n-bannerText> + </eg-staff-banner> +</ng-container> + +<!-- when displayed in the Holdings Editor's Administration tab --> +<div class="d-flex" *ngIf="route.snapshot.url.join('/') !== 'template_grid'"> + <h2 class="mt-3" i18n>Holdings Templates</h2> +</div> + +<!-- impact of these hidden things on accessibility? --> +<input type="file" class="d-none" #templateFile + (change)="importTemplates($event)" id="template-file-upload"/> +<a class="d-none" (click)="exportTemplates($event, true)" #exportLink + download="export_copy_templates.json" [href]="exportTemplateUrl()"> +</a> +<a class="d-none" (click)="exportTemplates($event, false)" #exportAllLink + download="export_copy_templates_all.json" [href]="exportTemplateUrl()"> +</a> + +<eg-grid #grid + persistKey="cat.volcopy.template_grid" + [dataSource]="dataSource" [cellTextGenerator]="cellTextGenerator" + (rowSelectionChange)="gridSelectionChange($event)" + (onRowActivate)="editSelected([$event])" + [sortable]="true" [filterable]="true" [allowNamedFilterSets]="false"> + + <eg-grid-toolbar-button [buttonStyle]="{success: true}" + label="Create Template" i18n-label + (onClick)="createTemplate($event)"> + </eg-grid-toolbar-button> + + <eg-grid-toolbar-button [buttonStyle]="{normal: true}" + label="Edit Selected" i18n-label + [disabled]="noSelectedRows" + (onClick)="editSelected($event)"> + </eg-grid-toolbar-button> + + <eg-grid-toolbar-button [buttonStyle]="{danger: true}" + label="Delete Selected" i18n-label + [disabled]="noSelectedRows" + (onClick)="deleteSelected($event)"> + </eg-grid-toolbar-button> + + <eg-grid-toolbar-button [buttonStyle]="{normal: true}" + label="Import" i18n-label + (onClick)="templateFile.click()"> + </eg-grid-toolbar-button> + + <!-- + The typical approach of wrapping a file input in a <label> results + in button-ish things that have slightly different dimensions. + Instead have a button activate a hidden file input. + <button type="button" class="btn btn-outline-dark me-2" (click)="templateFile.click()"> + <input type="file" class="d-none" #templateFile + (change)="importTemplate($event)" id="template-file-upload"/> + <span i18n>Import Templates</span> + </button> + <input type="file" class="d-none" #templateFile + (change)="importTemplate($event)" id="template-file-upload"/> + --> + + <eg-grid-toolbar-button [buttonStyle]="{normal: true}" + label="Export Selected" i18n-label + [disabled]="noSelectedRows" + (onClick)="exportSelected()"> + </eg-grid-toolbar-button> + + <eg-grid-toolbar-button [buttonStyle]="{normal: true}" + label="Export All" i18n-label + (onClick)="exportAllLink.click()"> + </eg-grid-toolbar-button> + <!-- + <a (click)="exportTemplate($event)" + download="export_copy_template.json" [href]="exportTemplateUrl()"> + <button type="button" class="btn btn-outline-dark me-2" i18n>Export All Templates</button> + </a> + --> + + <eg-grid-toolbar-action + label="Edit Selected Templates" i18n-label + [disabled]="noSelectedRows" + (onClick)="editSelected($event)"> + </eg-grid-toolbar-action> + + <eg-grid-toolbar-action + label="Delete Selected Templates" i18n-label + [disabled]="noSelectedRows" + (onClick)="deleteSelected($event)"> + </eg-grid-toolbar-action> + + <eg-grid-toolbar-action + label="Export Selected" i18n-label + [disabled]="noSelectedRows" + (onClick)="exportSelected()"> + </eg-grid-toolbar-action> + + <eg-grid-toolbar-action + label="Export All" i18n-label + (onClick)="exportAllLink.click()"> + </eg-grid-toolbar-action> + + <eg-grid-toolbar-action + label="Create Template" i18n-label + (onClick)="createTemplate($event)"> + </eg-grid-toolbar-action> + + <eg-grid-toolbar-action + label="Import" i18n-label + (onClick)="templateFile.click()"> + </eg-grid-toolbar-action> + + <eg-grid-column name="templateName" [index]="true" i18n-label label="Template Name" name="templateName"></eg-grid-column> + <eg-grid-column name="age_protect" [hidden]="true" i18n-label label="Age Protect"></eg-grid-column> + <eg-grid-column name="circ_as_type" [hidden]="true" i18n-label label="Circ As Type"></eg-grid-column> + <eg-grid-column name="circ_lib" [hidden]="false" i18n-label label="Circ Lib"></eg-grid-column> + <eg-grid-column name="circ_modifier" [hidden]="false" i18n-label label="Circ Modifier"></eg-grid-column> + <eg-grid-column name="circulate" [hidden]="true" i18n-label label="Circulate"></eg-grid-column> + <eg-grid-column name="copy_alerts" [hidden]="false" i18n-label label="Alerts"></eg-grid-column> + <eg-grid-column name="notes" [hidden]="false" i18n-label label="Notes"></eg-grid-column> + <eg-grid-column name="tags" [hidden]="false" i18n-label label="Tags"></eg-grid-column> + <eg-grid-column name="stat_cat_entries" [hidden]="false" i18n-label label="Stat Cats"></eg-grid-column> + <eg-grid-column name="copy_number" [hidden]="true" i18n-label label="Copy Number"></eg-grid-column> + <eg-grid-column name="cost" [hidden]="true" i18n-label label="Cost"></eg-grid-column> + <eg-grid-column name="deposit" [hidden]="true" i18n-label label="Deposit"></eg-grid-column> + <eg-grid-column name="deposit_amount" [hidden]="true" i18n-label label="Deposit Amount"></eg-grid-column> + <eg-grid-column name="fine_level" [hidden]="true" i18n-label label="Fine Level"></eg-grid-column> + <eg-grid-column name="floating" [hidden]="true" i18n-label label="Floating"></eg-grid-column> + <eg-grid-column name="holdable" [hidden]="true" i18n-label label="Holdable"></eg-grid-column> + <eg-grid-column name="loan_duration" [hidden]="true" i18n-label label="Loan Duration"></eg-grid-column> + <eg-grid-column name="location" [hidden]="true" i18n-label label="Location"></eg-grid-column> + <eg-grid-column name="mint_condition" [hidden]="true" i18n-label label="Mint Condition"></eg-grid-column> + <eg-grid-column name="opac_visible" [hidden]="true" i18n-label label="OPAC Visible"></eg-grid-column> + <eg-grid-column name="owning_lib" [hidden]="false" i18n-label label="Owning Lib"></eg-grid-column> + <eg-grid-column name="price" [hidden]="true" i18n-label label="Price"></eg-grid-column> + <eg-grid-column name="ref" [hidden]="true" i18n-label label="Reference"></eg-grid-column> + <eg-grid-column name="statcat_filter" [hidden]="true" i18n-label label="Stat Cat Filter"></eg-grid-column> + <eg-grid-column name="status" [hidden]="true" i18n-label label="Status"></eg-grid-column> + <eg-grid-column name="debug" [hidden]="true" i18n-label label="Debug JSON"></eg-grid-column> +</eg-grid> diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/template-grid.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/template-grid.component.ts new file mode 100644 index 0000000000..e6d111e9c0 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/template-grid.component.ts @@ -0,0 +1,435 @@ +/* eslint-disable max-len */ +import {Component, Input, OnInit, OnDestroy, ViewChild, ElementRef} from '@angular/core'; +import {Router, ActivatedRoute} from '@angular/router'; +import {Subject,Observable,of,from} from 'rxjs'; +import {takeUntil,tap,map} from 'rxjs/operators'; +import {SafeUrl} from '@angular/platform-browser'; +import {IdlService} from '@eg/core/idl.service'; +import {OrgService} from '@eg/core/org.service'; +import {AuthService} from '@eg/core/auth.service'; +import {BroadcastService} from '@eg/share/util/broadcast.service'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {GridDataSource, GridCellTextGenerator} from '@eg/share/grid/grid'; +import {GridFlatDataService} from '@eg/share/grid/grid-flat-data.service'; +import {Pager} from '@eg/share/util/pager'; +import {VolCopyContext} from './volcopy'; +import {VolCopyService} from './volcopy.service'; + +@Component({ + selector: 'eg-volcopy-template-grid', + templateUrl: 'template-grid.component.html' +}) +export class VolCopyTemplateGridComponent implements OnInit, OnDestroy { + + private destroy$ = new Subject<void>(); + + // In case we need it + @Input() embedContext: VolCopyContext; + context: VolCopyContext; + loading = true; + + @ViewChild('exportLink', { static: false }) private exportLink: ElementRef; + @ViewChild('grid', { static: true }) private grid: GridComponent; + @ViewChild('importSummaryDialog') private importSummaryDialog: any; + importResults: {section: string, items: string[]}[] = []; + + dataSource: GridDataSource = new GridDataSource(); + cellTextGenerator: GridCellTextGenerator; + noSelectedRows: boolean; + oneSelectedRow: boolean; + + constructor( + private router: Router, + public route: ActivatedRoute, + private idl: IdlService, + private org: OrgService, + private auth: AuthService, + private broadcaster: BroadcastService, + private flatData: GridFlatDataService, + public volcopy: VolCopyService + ) {} + + ngOnInit() { + // console.debug('VolCopyTemplateGridComponent, ngOnInit, this', this); + + this.initDataSource(); + + this.load().then( () => { + this.grid.reload(); + this.gridSelectionChange([]); // to disable certain actions + }); + + this.volcopy.templatesRefreshed$.pipe( + takeUntil(this.destroy$) + ).subscribe(() => { + // console.debug('VolCopyTemplateGridComponent, noticed templatesRefreshed$'); + setTimeout(() => { this.grid.reload(); }, 0); + }); + } + + load(): Promise<any> { + if (this.volcopy.currentContext) { + this.context = this.volcopy.currentContext; + // console.debug('VolCopyTemplateGridComponent, reusing currentContext'); + this.loading = false; + return Promise.resolve(); + } + + + this.context = new VolCopyContext(); + this.context.org = this.org; // inject; + this.context.idl = this.idl; // inject; + // console.debug('VolCopyTemplateGridComponent, new context'); + + this.volcopy.currentContext = this.context; + return this._load().then( () => { + console.debug('VolCopyTemplateGridComponent, templates (and other data) fetched for VolCopyService'); + }); + } + + _load(copyIds?: number[]): Promise<any> { + // this.sessionExpired = false; + this.loading = true; + this.context.reset(); + + return this.volcopy.load() + .then(result => { + this.loading = false; + return result; + }) + .catch(error => { + this.loading = false; + console.error('VolCopyTemplateGridComponent, error loading VolCopyService', error); + throw error; + }); + } + + gridSelectionChange(keys: string[]) { + this.updateSelectionState(keys); + } + + updateSelectionState(keys: string[]) { + this.noSelectedRows = (keys.length === 0); + this.oneSelectedRow = (keys.length === 1); + } + + convertTemplatesToGridRows(_templates: Record<string, any>): any[] { + // console.debug('VolCopyTemplateGridComponent, convertTemplatesToGridRows, _templates', JSON.stringify(_templates)); + const rows: any[] = []; + + for (const [templateName, templateConfig] of Object.entries(_templates)) { + const row: any = { + templateName, + location: templateConfig.location?.toString() || '', + cost: templateConfig.cost?.toString() || '', + fine_level: templateConfig.fine_level?.toString() || '', + circ_as_type: templateConfig.circ_as_type || '', + deposit: templateConfig.deposit === 't' ? $localize`Yes` : (templateConfig.deposit === 'f' ? $localize`No` : ''), + age_protect: templateConfig.age_protect?.toString() || '', + ref: templateConfig.ref === 't' ? $localize`Yes` : (templateConfig.ref === 'f' ? $localize`No` : ''), + status: templateConfig.status?.toString() || '', + circulate: templateConfig.circulate === 't' ? $localize`Yes` : (templateConfig.circulate === 'f' ? $localize`No` : ''), + // legacy templates will give us "statcats":{"1":null,"2":null}, leading to a count of two. Do we filter? + // I think no, because these nulls do get applied with the template. An absense of "statcats" is the correct way + // to have the template not affect stat cats, but the legacy interface couldn't do that with stat cats. + stat_cat_entries: templateConfig.statcats ? Object.entries( templateConfig.statcats ).length.toString() : '0', + /* stat_cat_entries: templateConfig.statcats + ? Object.entries(templateConfig.statcats) + .filter(([_, value]) => value !== null) + .length + .toString() + : '0',*/ + holdable: templateConfig.holdable === 't' ? $localize`Yes` : (templateConfig.holdable === 'f' ? $localize`No` : ''), + circ_lib: this.org.get( templateConfig.circ_lib )?.shortname() || '', + circ_modifier: templateConfig.circ_modifier || '', + opac_visible: templateConfig.opac_visible === 't' ? $localize`Yes` : (templateConfig.opac_visible === 'f' ? $localize`No` : ''), + floating: templateConfig.floating?.toString() || '', + price: templateConfig.price?.toString() || '', + mint_condition: templateConfig.mint_condition === 't' ? $localize`Yes` : (templateConfig.mint_condition === 'f' ? $localize`No` : ''), + deposit_amount: templateConfig.deposit_amount?.toString() || '', + loan_duration: templateConfig.loan_duration?.toString() || '', + copy_alerts: templateConfig.copy_alerts ? templateConfig.copy_alerts.length.toString() : '0', + notes: templateConfig.notes ? templateConfig.notes.length.toString() : '0', + tags: templateConfig.tags ? templateConfig.tags.length.toString() : '0', + statcat_filter: templateConfig.statcat_filter?.toString() || '', + owning_lib: this.org.get( templateConfig.owning_lib )?.shortname() || '', + copy_number: templateConfig.copy_number?.toString() || '', + debug: JSON.stringify(templateConfig) + }; + + if (row.age_protect) { + const rule = this.volcopy.commonData.acp_age_protect + .find(r => r.id() === Number(row.age_protect)); + row.age_protect = rule ? rule.name() : row.age_protect; + } + + if (row.circ_as_type) { + const type = this.volcopy.commonData.acp_item_type_map + .find(t => t.code() === row.circ_as_type); + row.circ_as_type = type ? type.value() : row.circ_as_type; + } + + if (row.fine_level) { + row.fine_level = { + 1: $localize`Low`, + 2: $localize`Normal`, + 3: $localize`High` + }[row.fine_level] || row.fine_level; + } + + if (row.floating) { + const group = this.volcopy.commonData.acp_floating_group + .find(g => g.id() === Number(row.floating)); + row.floating = group ? group.name() : row.floating; + } + + if (row.loan_duration) { + row.loan_duration = { + 1: $localize`Short`, + 2: $localize`Normal`, + 3: $localize`Long` + }[row.loan_duration] || row.loan_duration; + } + + if (row.location) { + this.volcopy.getLocation(row.location).then( loc => { // delayed fleshing for the win + // console.debug(`Fetched location for ${row.location}:`, loc); + row.location = loc ? + `${loc.name()} (${this.org.get(loc.owning_lib()).shortname()})` : + `Not found, ID: ${row.location}`; + }).catch(error => { + row.location = `Error with ID: ${row.location}`; + console.error(`Error fetching location ${row.location}:`,error); + }); + } + + if (row.status) { + const stat = this.volcopy.copyStatuses[row.status]; + row.status = stat ? stat.name() : row.status; + } + + rows.push(row); + } + + // console.debug('VolCopyTemplateGridComponent, convertTemplatesToGridRows, rows', JSON.stringify(rows)); + return rows; + } + + initDataSource() { + // console.debug('VolCopyTemplateGridComponent, initializing dataSource'); + this.dataSource.getRows = (pager: Pager, sort: any[]) => { + if (!this.volcopy.templates) { + console.debug('VolCopyTemplateGridComponent, no templates available yet'); + return from([]); // Return empty array if templates aren't loaded yet + } + + let filteredData = this.convertTemplatesToGridRows(this.volcopy.templates); + + // Apply filters + if (Object.keys(this.dataSource.filters).length > 0) { + filteredData = filteredData.filter(row => { + return Object.keys(this.dataSource.filters).every(key => { + const filters = this.dataSource.filters[key]; + + return filters.every(filterObj => { + const fieldName = Object.keys(filterObj)[0]; + + const filterDef = filterObj[fieldName]; + const operator = Object.keys(filterDef)[0]; + const filterValue = filterDef[operator]; + + const rowValue = row[fieldName]; + + if (fieldName === '-not') { + const notField = Object.keys(filterDef)[0]; + const notOp = Object.keys(filterDef[notField])[0]; + const notVal = filterDef[notField][notOp]; + return !this.matchFilter(row[notField], notOp, notVal); + } + + return this.matchFilter(rowValue, operator, filterValue); + }); + }); + }); + } + + if (sort && sort.length > 0) { + filteredData.sort((a, b) => { + for (const sortItem of sort) { + const dir = sortItem.dir === 'DESC' ? -1 : 1; + const aVal = a[sortItem.name]; + const bVal = b[sortItem.name]; + + if (aVal < bVal) {return -1 * dir;} + if (aVal > bVal) {return 1 * dir;} + } + return 0; + }); + } + + const start = pager.offset; + const end = pager.offset + pager.limit; + const pagedData = filteredData.slice(start, end); + + pager.resultCount = filteredData.length; + + return new Observable(subscriber => { + pagedData.forEach(row => subscriber.next(row)); + subscriber.complete(); + }); + }; + } + + private matchFilter(value: any, operator: string, filterValue: any): boolean { + if (value === undefined || value === null) { + if (operator === '=' && filterValue === null) {return true;} + if (operator === '!=' && filterValue === null) {return false;} + return false; + } + + switch (operator) { + case '=': + return filterValue === null ? + value === null : + String(value).toLowerCase() === String(filterValue).toLowerCase(); + case '!=': + return filterValue === null ? + value !== null : + String(value).toLowerCase() !== String(filterValue).toLowerCase(); + case 'like': + if (!filterValue) {return false;} + return new RegExp(filterValue.replace(/%/g, '.*'), 'i').test(String(value)); + case '>': + return Number(value) > Number(filterValue); + case '<': + return Number(value) < Number(filterValue); + case '>=': + return Number(value) >= Number(filterValue); + case '<=': + return Number(value) <= Number(filterValue); + case 'in': + return Array.isArray(filterValue) && filterValue.includes(value); + case 'not in': + return Array.isArray(filterValue) && !filterValue.includes(value); + default: + return false; + } + } + + exportSelected(rows?: any[]) { + if (!rows || !rows.length) { + rows = this.grid.context.getSelectedRows(); + } + + this.volcopy.templatesToExport = {}; // clear the old export set + rows.forEach(t => { + this.volcopy.templatesToExport[ t.templateName ] = this.volcopy.templates[ t.templateName ]; + }); + console.debug('Templates to export: ', this.volcopy.templatesToExport); + + if (Object.keys(this.volcopy.templatesToExport).length) { + setTimeout(() => { + // we need a proper click event when we pass this over to the file service + this.exportLink.nativeElement.click(); + }); + } + } + + exportTemplates($event = null, selected = false) { + return this.volcopy.exportTemplate($event, selected); + } + + importTemplates($event) { + return this.volcopy.importTemplate($event) + .then(result => { + $event.target.value = ''; // reset file selection so we can re-upload if desired + this.importResults = []; + + if (result.added.length > 0) { + this.importResults.push({ + section: $localize`New Templates Added`, + items: result.added + }); + } + + if (result.overwritten.length > 0) { + this.importResults.push({ + section: $localize`Existing Templates Updated`, + items: result.overwritten + }); + } + + if (this.importResults.length === 0) { + this.importResults.push({ + section: $localize`Results`, + items: [$localize`No templates were imported`] + }); + } + + this.importSummaryDialog.open(); + }) + .catch(error => { + this.importResults = [{ + section: $localize`Error`, + items: [$localize`Error importing templates: ${error.message}`] + }]; + this.importSummaryDialog.open(); + }); + } + + // Returns null when no export is in progress. + exportTemplateUrl(): SafeUrl { + return this.volcopy.exportTemplateUrl(); + } + + createTemplate($event) { + console.debug('createTemplate', $event); + const url = '/eg2/staff/cat/volcopy/template'; + window.open(url, '_blank'); + } + + + + editSelected(rows) { + if (!rows.length) { + rows = this.grid.context.getSelectedRows(); + } + + rows.forEach(t => { + const base64Name = btoa(encodeURIComponent(t.templateName)); + const target_url = `/eg2/staff/cat/volcopy/template/${base64Name}`; + console.debug('opening edit tab for ' + t.templateName, target_url); + try { + window.open(target_url, '_blank'); + } catch(E) { + console.error('error opening edit tab for ' + t.templateName, E); + } + }); + } + + deleteSelected(rows) { + if (!rows.length) { return false; } + if (! window.confirm( + rows.length > 1 + ? $localize`Delete selected templates?` + : $localize`Delete selected template?` + )) { + return; + } + this.volcopy.deleteTemplates(rows.map( t => t.templateName )).then(result => { + console.log('Deleted templates:', result.deleted); + console.log('Templates not found:', result.notFound); + }).catch(error => { + console.error('Error deleting templates:', error); + }).finally(() => { + this.grid.reload(); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} + + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.html b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.html index 0172a56b7e..818b37f877 100644 --- a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.html +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.html @@ -33,8 +33,10 @@ <ng-container *ngIf="displayColumn('classification')"> <div><label class="form-label fw-bold" i18n>Classification</label></div> <div> - <eg-combobox [smallFormControl]="true" [(ngModel)]="batchVolClass"> + <eg-combobox [smallFormControl]="true" [(ngModel)]="batchVolClass" + [startId]="volcopy.defaults?.values['classification']"> <eg-combobox-entry *ngFor="let cls of volcopy.commonData.acn_class" + [selected]="batchVolClass === cls.id()" [entryId]="cls.id()" [entryLabel]="cls.name()"> </eg-combobox-entry> </eg-combobox> diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.ts index 614caea692..f9c6429fde 100644 --- a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/vol-edit.component.ts @@ -629,6 +629,7 @@ export class VolEditComponent implements OnInit { canSave(): boolean { + // console.debug('VolEditComponent, canSave()'); const copies = this.context.copyList(); const badCopies = copies.filter(copy => { @@ -643,6 +644,7 @@ export class VolEditComponent implements OnInit { } }).length > 0; + // console.debug('VolEditComponent, canSave(), badCopies', badCopies); if (badCopies) { return false; } const badVols = this.context.volNodes().filter(volNode => { @@ -662,6 +664,7 @@ export class VolEditComponent implements OnInit { } } }).length > 0; + // console.debug('VolEditComponent, canSave(), badVols', badVols); return !badVols; } diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.css b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.css new file mode 100644 index 0000000000..cf8f638e4c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.css @@ -0,0 +1,4 @@ +.volcopy-actions button .warning-wrapper { + display: inline-flex; + flex-direction: row-reverse; +} \ No newline at end of file diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.html b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.html index 96dc50c978..fd9d8a9dff 100644 --- a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.html +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.html @@ -14,10 +14,10 @@ {{cnPrefixName(node.target.prefix())}} {{node.target.label()}} {{cnSuffixName(node.target.suffix())}} - @ {{orgName(node.target.owning_lib())}} + @ {{orgName(node.target.owning_lib())}} </div> <ul> - <li *ngFor="let child of node.children">{{ child.target.barcode() }} @ {{orgName(child.target.circ_lib())}}</li> + <li *ngFor="let child of node.children">{{ child.target.barcode() }} @ {{orgName(child.target.circ_lib())}}</li> </ul> </li> </ul> @@ -33,8 +33,11 @@ </eg-op-change> <div class="row" *ngIf="sessionExpired"> - <div class="col-lg-6 mt-4 offset-lg-3 alert alert-danger d-flex justify-content-center" i18n> - Holdings Editor Session Expired + <div class="col-lg-6 mt-4 offset-lg-3 alert alert-danger justify-content-center" role="text"> + <span i18n>Holdings data is no longer available; it may have changed in another browser tab.</span> + <span *ngIf="recordId" i18n> + You can <a class="alert-link" href="/staff/catalog/record/{{recordId}}/">revisit the record page</a> to start a new editing session. + </span> </div> </div> @@ -49,6 +52,7 @@ <li role="presentation" ngbNavItem="holdings"> <a role="tab" ngbNavLink i18n>Holdings</a> <ng-template ngbNavContent> + <h3 class="visually-hidden" i18n>Holdings</h3> <div class="mt-2"> <eg-vol-edit [context]="context" (canSaveChange)="volsCanSaveChange($event)"></eg-vol-edit> @@ -56,7 +60,7 @@ <ng-container *ngIf="volcopy.defaults.values.unified_display"> <div class="mt-2"> <eg-copy-attrs [context]="context" [contextChanged]="contextChange.asObservable()" #copyAttrs - (canSaveChange)="attrsCanSaveChange($event)"></eg-copy-attrs> + (clearChanges)="clearChangesReaction($event)" (canSaveChange)="attrsCanSaveChange($event)"></eg-copy-attrs> </div> </ng-container> </ng-template> @@ -65,9 +69,10 @@ <li role="presentation" ngbNavItem="attrs"> <a role="tab" ngbNavLink i18n>Item Attributes</a> <ng-template ngbNavContent> + <h3 class="visually-hidden" i18n>Item Attributes</h3> <div class="mt-2"> <eg-copy-attrs [context]="context" [contextChanged]="contextChange.asObservable()" #copyAttrs - (canSaveChange)="attrsCanSaveChange($event)"></eg-copy-attrs> + (clearChanges)="clearChangesReaction($event)" (canSaveChange)="attrsCanSaveChange($event)"></eg-copy-attrs> </div> </ng-template> </li> @@ -80,13 +85,25 @@ </div> </ng-template> </li> + <li role="presentation" ngbNavItem="templates"> + <a role="tab" ngbNavLink i18n>Administration</a> + <ng-template ngbNavContent> + <div class="mt-2"> + <eg-volcopy-template-grid [embedContext]="context"></eg-volcopy-template-grid> + </div> + </ng-template> + </li> </ul> <div [ngbNavOutlet]="holdingsNav"></div> <ng-container *ngIf="tab === 'holdings' || tab === 'attrs'"> - <hr class="m-2"/> - <div class="row m-2 p-2 border border-dark rounded bg-faint"> - <div class="col-lg-12 d-flex"> + <hr class="m-2"/> + <div class="row"> + <p *ngIf="changesPendingForStatusBar" class="col-12 alert alert-warning text-center" i18n>Changes Pending</p> + <p *ngIf="barcodeNeeded()" class="col-12 alert alert-warning text-center" i18n>At least one barcoded item needed for current changes.</p> + </div> + <div class="row"> + <div class="col-lg-12 d-flex align-items-start volcopy-actions"> <div class="form-check form-check-inline ms-2"> <input class="form-check-input" id='use-labels-cbox' type="checkbox" @@ -121,13 +138,15 @@ </ng-container> <div class="flex-1"> </div> - <p *ngIf="changesPendingForStatusBar" class="alert alert-warning" i18n>Changes Pending</p> - <button type="button" class="btn btn-outline-dark" (click)="save(false, true)" - [ngClass]="{'border-danger': isNotSaveable()}" - [disabled]="isNotSaveable()" i18n>Apply All & Save</button> - <button type="button" class="btn btn-outline-dark ms-2" (click)="save(true, true)" - [ngClass]="{'border-danger': isNotSaveable()}" - [disabled]="isNotSaveable()" i18n>Apply All, Save & Exit</button> + + <button type="button" class="btn btn-outline-primary" (click)="save(false, true)" [disabled]="isNotSaveable()"> + <span class="material-icons" aria-hidden="true" *ngIf="isNotSaveable()">warning</span> + <span i18n>Apply All & Save</span> + </button> + <button type="button" class="btn btn-primary" (click)="save(true, true)" [disabled]="isNotSaveable()"> + <span class="material-icons" aria-hidden="true" *ngIf="isNotSaveable()">warning</span> + <span i18n>Apply All, Save & Exit</span> + </button> </div> </div> </ng-container> diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.ts index 1767b56b61..b01b1c7f32 100644 --- a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.ts @@ -1,4 +1,5 @@ -import {Component, OnInit, ViewChild, HostListener} from '@angular/core'; +import {DOCUMENT, ViewportScroller} from '@angular/common'; +import {Component, OnInit, ViewChild, HostListener, inject, NgZone, ElementRef} from '@angular/core'; import {Router, ActivatedRoute, ParamMap} from '@angular/router'; import {BehaviorSubject, from, Observable, of} from 'rxjs'; import {catchError, finalize, switchMap, tap, map} from 'rxjs/operators'; @@ -23,7 +24,7 @@ import {BroadcastService} from '@eg/share/util/broadcast.service'; import {CopyAttrsComponent} from './copy-attrs.component'; const COPY_FLESH = { - flesh: 1, + flesh: 3, flesh_fields: { acp: [ 'call_number', 'location', 'parts', 'tags', @@ -54,7 +55,8 @@ interface EditSession { } @Component({ - templateUrl: 'volcopy.component.html' + templateUrl: 'volcopy.component.html', + styleUrls: ['./volcopy.component.css'] }) export class VolCopyComponent implements OnInit { @@ -68,6 +70,7 @@ export class VolCopyComponent implements OnInit { tab = 'holdings'; // holdings | attrs | config target: string; // item | callnumber | record | session targetId: string; // id value or session string + recordId: string; // record ID, for returning after single copy editing session expires volsCanSave = true; attrsCanSave = true; @@ -87,6 +90,8 @@ export class VolCopyComponent implements OnInit { @ViewChild('volEditOpChange', {static: false}) volEditOpChange: OpChangeComponent; + private _document = inject(DOCUMENT); + constructor( private router: Router, private route: ActivatedRoute, @@ -101,11 +106,17 @@ export class VolCopyComponent implements OnInit { private cache: AnonCacheService, private broadcaster: BroadcastService, private holdings: HoldingsService, - private volcopy: VolCopyService + private volcopy: VolCopyService, + protected vs: ViewportScroller ) { } ngOnInit() { - console.log('VolCopyComponent, this',this); + // console.debug('VolCopyComponent, this',this); + + // always scroll up a little because of our fixed navbar + const y = this._document.getElementById('staff-navbar').offsetHeight * 2; + this.vs.setOffset([0, y]); // [x, y] offset from viewscroll destination + this.route.paramMap.subscribe( (params: ParamMap) => this.negotiateRoute(params)); } @@ -137,9 +148,9 @@ export class VolCopyComponent implements OnInit { return modalRef.pipe( tap({ - next: res => console.log('VolCopyComponent, OpChangeComponent emission', res), + next: res => console.debug('VolCopyComponent, OpChangeComponent emission', res), error: (err: unknown) => console.error('VolCopyComponent, OpChangeComponent error', err), - complete: () => console.log('VolCopyComponent, OpChangeComponent complete') + complete: () => console.debug('VolCopyComponent, OpChangeComponent complete') }), finalize(() => { const opChangeData = { instigated: true, key: this.target + ':' + this.targetId }; @@ -233,6 +244,7 @@ export class VolCopyComponent implements OnInit { this.tab = params.get('tab') || 'holdings'; this.target = params.get('target'); this.targetId = params.get('target_id'); + this.recordId = this.route.snapshot.queryParamMap.get('record_id'); if (this.volcopy.currentContext) { // Avoid clobbering the context on route change. @@ -240,6 +252,7 @@ export class VolCopyComponent implements OnInit { } else { this.context = new VolCopyContext(); this.context.org = this.org; // inject; + this.context.idl = this.idl; // inject; } switch (this.target) { @@ -332,7 +345,7 @@ export class VolCopyComponent implements OnInit { `/staff/cat/volcopy/${this.tab}/${this.target}/${this.targetId}`; // Retain search parameters - this.router.navigate([url], {queryParamsHandling: 'merge'}); + this.router.navigate([url], {queryParamsHandling: 'merge', queryParams: {record_id: this.recordId}}); } fetchSession(session: string): Promise<any> { @@ -437,6 +450,7 @@ export class VolCopyComponent implements OnInit { const ids = [].concat(copyIds); if (ids.length === 0) { return Promise.resolve(); } return this.pcrud.search('acp', {id: ids}, COPY_FLESH) + .pipe(tap(copy => copy.copy_alerts( copy.copy_alerts().filter( a=>!a.ack_time() ) ) )) .pipe(tap(copy => this.context.findOrCreateCopyNode(copy))) .toPromise(); } @@ -476,6 +490,7 @@ export class VolCopyComponent implements OnInit { } save(close?: boolean): Promise<any> { + // console.debug('save', this); this.loading = true; if (this.copyAttrs) { @@ -483,20 +498,27 @@ export class VolCopyComponent implements OnInit { this.copyAttrs.applyPendingChanges(); } + // this.context.updateInMemoryCopies(); + // Volume update API wants volumes fleshed with copies, instead // of the other way around, which is what we have here. const volumes: IdlObject[] = []; this.context.volNodes().forEach(volNode => { - const newVol = this.idl.clone(volNode.target); + console.warn('save, considering volNode', volNode); + const newVol = this.idl.clone( this.idl.fromHash(volNode.target, 'acn') ); + console.warn('save, newVol', newVol); const copies: IdlObject[] = []; volNode.children.forEach(copyNode => { - const copy = copyNode.target; + console.warn('save, considering copyNode', copyNode); + const copy = this.idl.fromHash(copyNode.target, 'acp'); + console.warn('save, copy', copy); if (copy.isnew() && !copy.barcode()) { // A new copy w/ no barcode is a stub copy sitting // on an empty call number. Ignore it. + console.warn('isnew but no barcode, skipping', copy); return; } @@ -504,12 +526,16 @@ export class VolCopyComponent implements OnInit { // without any changes to the copies. This ensures the // API knows when we are modifying a subset of the total // copies on a volume, e.g. when changing volume labels - if (newVol.ischanged()) { copy.ischanged(true); } + if (newVol.ischanged() && !copy.isnew() && !copy.isdeleted()) { + // console.debug('setting ischanged based on newVol.ischanged'); + copy.ischanged(true); + } if (copy.ischanged() || copy.isnew() || copy.isdeleted()) { const copyClone = this.idl.clone(copy); // De-flesh call number copyClone.call_number(copy.call_number().id()); + // console.debug('pushing copyClone into newVol.copies', copyClone); copies.push(copyClone); } }); @@ -517,12 +543,14 @@ export class VolCopyComponent implements OnInit { newVol.copies(copies); if (newVol.ischanged() || newVol.isnew() || copies.length > 0) { + // console.debug('pushing newVol into volumes', newVol); volumes.push(newVol); } }); this.context.volsToDelete.forEach(vol => { const cloneVol = this.idl.clone(vol); + // console.debug('volsToDelete, cloneVol', cloneVol); // No need to flesh copies -- they'll be force deleted. cloneVol.copies([]); volumes.push(cloneVol); @@ -530,6 +558,7 @@ export class VolCopyComponent implements OnInit { this.context.copiesToDelete.forEach(copy => { const cloneCopy = this.idl.clone(copy); + // console.debug('copiesToDelete, cloneCopy', cloneCopy); const copyVol = cloneCopy.call_number(); cloneCopy.call_number(copyVol.id()); // de-flesh @@ -550,6 +579,7 @@ export class VolCopyComponent implements OnInit { vol.copies().forEach(copy => { ['editor', 'creator', 'location'].forEach(field => { if (typeof copy[field]() === 'object') { + // console.debug('copy[\'' + field + '\']',copy[field]()); copy[field](copy[field]().id()); } }); @@ -559,10 +589,14 @@ export class VolCopyComponent implements OnInit { let promise: Promise<number[]> = Promise.resolve([]); if (volumes.length > 0) { + // console.debug('fleshed volumes getting posted', volumes); promise = this.saveApi(volumes, false, close); + } else { + console.warn('nothing getting saved', this); } return promise.then(copyIds => { + // console.debug('save response, copyIds', copyIds); // In addition to the copies edited in this update call, // reload any other copies that were previously loaded (and permitted). const ids: any = {}; // dedupe @@ -631,6 +665,7 @@ export class VolCopyComponent implements OnInit { saveApi(volumes: IdlObject[], override?: boolean, close?: boolean): Promise<number[]> { + // console.debug('saveApi, volumes, override, close', volumes, override, close); let method = 'open-ils.cat.asset.volume.fleshed.batch.update'; if (override) { method += '.override'; } @@ -695,6 +730,7 @@ export class VolCopyComponent implements OnInit { if (!this.volsCanSave) { return true; } if (!this.attrsCanSave) { return true; } + if (this.barcodeNeeded()) { return true; } // This can happen regardless of whether we are modifying // volumes vs. copies. @@ -704,17 +740,56 @@ export class VolCopyComponent implements OnInit { } volsCanSaveChange(can: boolean) { + // console.debug('volsCanSaveChange, volsCanSave', can); this.volsCanSave = can; this.changesPending = true; this.changesPendingForStatusBar = true; } attrsCanSaveChange(can: boolean) { + // console.debug('attrsCanSaveChange, attrsCanSave', can); this.attrsCanSave = can; this.changesPending = true; this.changesPendingForStatusBar = true; } + clearChangesReaction(r: any) { + // console.debug('clearChangesReaction', r); + // trigger a clear changes type action in non-unified volume editor? + this.attrsCanSave = false; + this.changesPending = false; + this.changesPendingForStatusBar = false; + } + + barcodeNeeded() { + if (!this.changesPendingForStatusBar) { return false; } + if (!this.copyAttrs) { return false; } + if (!this.copyAttrs.batchAttrs) { return false; } + if (!this.context) { return false; } + const hasCopyLevelChanges = + this.context.newAlerts.length > 0 || + this.context.changedAlerts.length > 0 || + this.context.newNotes.length > 0 || + this.context.deletedNotes.length > 0 || + this.context.changedNotes.length > 0 || + this.context.newTagMaps.length > 0 || + this.context.changedTagMaps.length > 0 || + this.context.deletedTagMaps.length > 0 || + this.copyAttrs.batchAttrs.filter( + attr => ! ['label_class', 'prefix', 'suffix'].includes( attr.name ) + ).filter( + attr => attr.hasChanged + ).length > 0; + let barcodeCount = 0; + this.context.copyList().forEach( copy => { + if ( typeof copy.barcode() === 'string' && copy.barcode() !== '' ) { + barcodeCount++; + } + }); + const result = hasCopyLevelChanges && barcodeCount === 0; + return result; + } + @HostListener('window:beforeunload', ['$event']) canDeactivate($event?: Event): boolean | Promise<boolean> { diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.module.ts b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.module.ts index 0c6f6add76..434e81b5e1 100644 --- a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.module.ts @@ -10,6 +10,8 @@ import {VolCopyService} from './volcopy.service'; import {CopyAttrsComponent} from './copy-attrs.component'; import {ItemLocationSelectModule} from '@eg/share/item-location-select/item-location-select.module'; import {VolCopyConfigComponent} from './config.component'; +import {VolCopyTemplateGridComponent} from './template-grid.component'; +import {VolCopyTemplateEditComponent} from './template-edit.component'; import {VolCopyPermissionDialogComponent} from './vol-copy-permission-dialog.component'; @NgModule({ @@ -18,6 +20,8 @@ import {VolCopyPermissionDialogComponent} from './vol-copy-permission-dialog.com VolEditComponent, CopyAttrsComponent, VolCopyConfigComponent, + VolCopyTemplateGridComponent, + VolCopyTemplateEditComponent, VolCopyPermissionDialogComponent, VolEditPartDedupePipe ], diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.service.ts b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.service.ts index 251d6f1ab7..25474ae1d6 100644 --- a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.service.ts +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.service.ts @@ -1,5 +1,8 @@ -import {Injectable, EventEmitter} from '@angular/core'; -import {tap} from 'rxjs/operators'; +/* eslint-disable max-len, no-prototype-builtins */ +import {Injectable, EventEmitter, OnDestroy} from '@angular/core'; +import {Subject} from 'rxjs'; +import {tap, takeUntil} from 'rxjs/operators'; +import {SafeUrl} from '@angular/platform-browser'; import {IdlService, IdlObject} from '@eg/core/idl.service'; import {NetService} from '@eg/core/net.service'; import {OrgService} from '@eg/core/org.service'; @@ -8,6 +11,7 @@ import {EventService} from '@eg/core/event.service'; import {AuthService} from '@eg/core/auth.service'; import {VolCopyContext} from './volcopy'; import {HoldingsService} from '@eg/staff/share/holdings/holdings.service'; +import {FileExportService} from '@eg/share/util/file-export.service'; import {ServerStoreService} from '@eg/core/server-store.service'; import {StoreService} from '@eg/core/store.service'; import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; @@ -24,13 +28,16 @@ interface VolCopyDefaults { } @Injectable() -export class VolCopyService { +export class VolCopyService implements OnDestroy { autoId = -1; localOrgs: number[]; defaults: VolCopyDefaults = null; copyStatuses: {[id: number]: IdlObject} = {}; + acnLabelClasses: {[id: number]: IdlObject} = {}; + acnPrefixes: {[id: number]: IdlObject} = {}; + acnSuffixes: {[id: number]: IdlObject} = {}; bibParts: {[bibId: number]: IdlObject[]} = {}; // This will be all 'local' copy locations plus any remote @@ -42,8 +49,13 @@ export class VolCopyService { statCatEntryMap: {[id: number]: IdlObject} = {}; // entry id => entry + private destroy$ = new Subject<void>; + private templatesRefreshed = new Subject<void>(); + templatesRefreshed$ = this.templatesRefreshed.asObservable(); + templateNames: ComboboxEntry[] = []; templates: any = {}; + templatesToExport: any = {}; commonData: {[key: string]: IdlObject[]} = {}; magicCopyStats: number[] = []; @@ -61,40 +73,81 @@ export class VolCopyService { private auth: AuthService, private pcrud: PcrudService, private holdings: HoldingsService, + private fileExport: FileExportService, private store: StoreService, private serverStore: ServerStoreService - ) {} + ) { + // Listen for ServerStoreService cache invalidation completions within this tab + this.serverStore.cacheCleared$ + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.fetchTemplates().then(() => { + this.templatesRefreshed.next(); + }); + }); + } + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } // Fetch the data that is always needed. load(): Promise<any> { + console.debug('VolCopyService.load() starting'); - if (this.commonData.acp_item_type_map) { return Promise.resolve(); } + if (this.commonData.acp_item_type_map) { + console.debug('VolCopyService.load() - commonData already loaded, returning early'); + return Promise.resolve(); + } this.localOrgs = this.org.fullPath(this.auth.user().ws_ou(), true); + console.debug('VolCopyService.load() - localOrgs set:', this.localOrgs); this.hideVolOrgs = this.org.list() .filter(o => !this.org.canHaveVolumes(o)).map(o => o.id()); + console.debug('VolCopyService.load() - hideVolOrgs set:', this.hideVolOrgs); return this.net.request( 'open-ils.cat', 'open-ils.cat.volcopy.data', this.auth.token() ).pipe(tap(dataset => { + console.debug('VolCopyService.load() - received dataset:', dataset); const key = Object.keys(dataset)[0]; this.commonData[key] = dataset[key]; })).toPromise() - .then(_ => this.ingestCommonData()) - - // These will come up later -- prefetch. - .then(_ => this.serverStore.getItemBatch([ - 'cat.copy.templates', - 'eg.cat.volcopy.defaults', - 'eg.cat.record.summary.collapse' - ])) - - .then(_ => this.holdings.getMagicCopyStatuses()) - .then(stats => this.magicCopyStats = stats) - .then(_ => this.fetchDefaults()) - .then(_ => this.fetchTemplates()); + .then(result => { + console.debug('VolCopyService.load() - after initial data fetch:', result); + return this.ingestCommonData(); + }) + .then(result => { + console.debug('VolCopyService.load() - after ingestCommonData'); + return this.serverStore.getItemBatch([ + 'cat.copy.templates', + 'eg.cat.volcopy.defaults', + 'eg.cat.record.summary.collapse' + ]); + }) + .then(batchResult => { + console.debug('VolCopyService.load() - after getItemBatch:', batchResult); + return this.holdings.getMagicCopyStatuses(); + }) + .then(stats => { + console.debug('VolCopyService.load() - got magicCopyStats:', stats); + this.magicCopyStats = stats; + return this.fetchDefaults(); + }) + .then(result => { + console.debug('VolCopyService.load() - after fetchDefaults'); + return this.fetchTemplates(); + }) + .then(result => { + console.debug('VolCopyService.load() - after fetchTemplates, templates:', this.templates); + return result; + }) + .catch(error => { + console.error('VolCopyService.load() - Error in promise chain:', error); + throw error; + }); } ingestCommonData() { @@ -102,6 +155,13 @@ export class VolCopyService { this.commonData.acp_location.forEach( loc => this.copyLocationMap[loc.id()] = loc); + // We want the magic -1 id for these + this.commonData.acn_prefix.forEach( + prefix => this.acnPrefixes[prefix.id()] = prefix); + + this.commonData.acn_suffix.forEach( + suffix => this.acnSuffixes[suffix.id()] = suffix); + // Remove the -1 prefix and suffix so they can be treated // specially in the markup. this.commonData.acn_prefix = @@ -110,6 +170,9 @@ export class VolCopyService { this.commonData.acn_suffix = this.commonData.acn_suffix.filter(sfx => sfx.id() !== -1); + this.commonData.acn_class.forEach( + label_class => this.acnLabelClasses[label_class.id()] = label_class); + this.commonData.acp_status.forEach( stat => this.copyStatuses[stat.id()] = stat); @@ -125,31 +188,65 @@ export class VolCopyService { } return this.pcrud.retrieve('acpl', id) - .pipe(tap(loc => this.copyLocationMap[loc.id()] = loc)) + .pipe(tap(loc => { + console.debug(`getLocation(${id})`,loc); + this.copyLocationMap[loc.id()] = loc; + })) .toPromise(); } fetchTemplates(): Promise<any> { - + console.debug('VolCopyService.fetchTemplates() starting'); return this.serverStore.getItem('cat.copy.templates') .then(templates => { - - if (!templates) { return null; } - + console.debug('VolCopyService.fetchTemplates() - received templates:', templates); + if (!templates) { + console.debug('VolCopyService.fetchTemplates() - no templates found'); + return null; + } this.templates = templates; - this.templateNames = Object.keys(templates) .sort((n1, n2) => n1 < n2 ? -1 : 1) .map(name => ({id: name, label: name})); - + console.debug('VolCopyService.fetchTemplates() - templateNames set:', this.templateNames); this.store.removeLocalItem('cat.copy.templates'); + return templates; + }) + .catch(error => { + console.error('VolCopyService.fetchTemplates() - Error:', error); + throw error; }); } - saveTemplates(): Promise<any> { + console.debug('saving cat.copy.templates', this.templates); return this.serverStore.setItem('cat.copy.templates', this.templates) - .then(() => this.fetchTemplates()); + .then(() => this.templates); + } + + deleteTemplates(templateNames: string[]): Promise<any> { + if (!this.templates) { + return Promise.reject(new Error('Templates not initialized')); + } + + const deletedTemplates: string[] = []; + const notFoundTemplates: string[] = []; + + templateNames.forEach(name => { + if (this.templates.hasOwnProperty(name)) { + delete this.templates[name]; + deletedTemplates.push(name); + } else { + notFoundTemplates.push(name); + } + }); + + this.templateNames = this.templateNames.filter(entry => !deletedTemplates.includes(entry.id)); + + return this.saveTemplates().then(() => ({ + deleted: deletedTemplates, + notFound: notFoundTemplates + })); } fetchDefaults(): Promise<any> { @@ -194,6 +291,7 @@ export class VolCopyService { vol.record(recordId); vol.label(null); vol.owning_lib(Number(orgId)); + vol.label_class(this.defaults.values.classification || 1); vol.prefix(this.defaults.values.prefix || -1); vol.suffix(this.defaults.values.suffix || -1); @@ -442,8 +540,10 @@ export class VolCopyService { } restrictCopyDelete(statId: number): boolean { - return this.copyStatuses[statId] && - this.copyStatuses[statId].restrict_copy_delete() === 't'; + return this.copyStatuses[statId] && ( + this.copyStatuses[statId].restrict_copy_delete() === 't' + || this.copyStatuses[statId].restrict_copy_delete() === true + ); } // Returns true if any items are missing values for a required stat cat. @@ -454,7 +554,7 @@ export class VolCopyService { if (!copy.barcode()) { return; } this.commonData.acp_stat_cat.forEach(cat => { - if (cat.required() !== 't') { return; } + if (cat.required() !== 't' && cat.required() !== true) { return; } const matches = copy.stat_cat_entries() .filter(e => e.stat_cat() === cat.id()); @@ -467,5 +567,95 @@ export class VolCopyService { return missing; } + + exportTemplate($event, selected) { + const exportList = selected ? this.templatesToExport : this.templates; + // console.debug('We will export templates: ', exportList); + this.fileExport.exportFile( + $event, JSON.stringify(exportList), 'text/json'); + } + + importTemplate($event): Promise<{added: string[], overwritten: string[]}> { + const file: File = $event.target.files[0]; + if (!file) { + return Promise.resolve({ added: [], overwritten: [] }); + } + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.addEventListener('load', () => { + try { + const template = JSON.parse(reader.result as string); + const added: string[] = []; + const overwritten: string[] = []; + const theKeys = Object.keys(template); + + for (let i = 0; i < theKeys.length; i++) { + const name = theKeys[i]; + const data = template[name]; + + // backwards compatibility + if (data.copy_notes && !data.notes) { + data.notes = data.copy_notes; + delete data.copy_notes; + } + + // convert actual booleans to 't' and 'f' + Object.keys(data).forEach(key => { + if (this.idl.classes.acp.field_map[key]?.datatype === 'bool') { + if (data[key] === true) { data[key] = 't'; } + else if (data[key] === false) { data[key] = 'f'; } + } + }); + + // same for alerts, notes, and tags + const ant = { alerts: 'aca', notes: 'acpn', tags: 'acpt' }; + Object.keys(ant).forEach(thing => { + if (data[thing] && Array.isArray(data[thing])) { + data[thing].forEach(thingElement => { + Object.keys(thingElement).forEach(key => { + if (this.idl.classes[ant[thing]].field_map[key].datatype === 'bool') { + if (thingElement[key] === true) { thingElement[key] = 't'; } + else if (thingElement[key] === false) { thingElement[key] = 'f'; } + } + }); + }); + } + }); + + // Check if template already exists + if (this.templates.hasOwnProperty(name)) { + overwritten.push(name); + } else { + added.push(name); + } + this.templates[name] = data; + } + + this.saveTemplates().then(() => { + // Adds the new ones to the list and re-sorts the labels + return this.fetchTemplates(); + }).then(() => { + this.templatesRefreshed.next(); + resolve({ added, overwritten }); + }).catch(error => { + reject(error); + }); + + } catch (E) { + console.error('Invalid Item Attribute template', E); + reject(E); + } + }); + + reader.readAsText(file); + }); + } + + // Returns null when no export is in progress. + exportTemplateUrl(): SafeUrl { + return this.fileExport.safeUrl; + } } diff --git a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.ts b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.ts index 155be747de..4990c93cb7 100644 --- a/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.ts +++ b/Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.ts @@ -1,4 +1,4 @@ -import {IdlObject} from '@eg/core/idl.service'; +import {IdlObject, IdlService} from '@eg/core/idl.service'; import {OrgService} from '@eg/core/org.service'; /* Models the holdings tree and manages related data shared @@ -25,6 +25,7 @@ export class VolCopyContext { holdings: HoldingsTree = new HoldingsTree(); org: OrgService; // injected + idl: IdlService; // why are these not in a constructor? sessionType: 'copy' | 'vol' | 'record' | 'mixed'; @@ -42,11 +43,28 @@ export class VolCopyContext { fastAdd: boolean; + newAlerts: IdlObject[] = []; + changedAlerts: IdlObject[] = []; + deletedAlerts: IdlObject[] = []; + newNotes: IdlObject[] = []; + deletedNotes: IdlObject[] = []; + changedNotes: IdlObject[] = []; + newTagMaps: IdlObject[] = []; + changedTagMaps: IdlObject[] = []; + deletedTagMaps: IdlObject[] = []; volsToDelete: IdlObject[] = []; copiesToDelete: IdlObject[] = []; reset() { this.holdings = new HoldingsTree(); + this.newAlerts = []; + this.changedAlerts = []; + this.newNotes = []; + this.deletedNotes = []; + this.changedNotes = []; + this.newTagMaps = []; + this.changedTagMaps = []; + this.deletedTagMaps = []; this.volsToDelete = []; this.copiesToDelete = []; } @@ -220,4 +238,344 @@ export class VolCopyContext { return false; } + + updateInMemoryCopies() { + console.debug('updateInMemoryCopies', this); + this.updateInMemoryCopiesWithAlerts(); + this.updateInMemoryCopiesWithNotes(); + this.updateInMemoryCopiesWithTags(); + } + + updateInMemoryCopiesWithAlerts() { + console.debug('updateInMemoryCopiesWithAlerts', this); + this.copyList().forEach(copy => { + this.updateInMemoryCopyWithAlerts(copy); + }); + } + + updateInMemoryCopyWithAlerts(copy) { + console.debug('updateInMemoryCopyWithAlerts, considering copy', copy.id(), copy); + console.debug('with this.newAlerts', this.newAlerts.length, this.newAlerts); + console.debug('with this.changedAlerts', this.changedAlerts.length, this.changedAlerts); + console.debug('with this.deletedAlerts', this.deletedAlerts.length, this.deletedAlerts); + + // Initialize array if needed + if (!copy.copy_alerts()) { copy.copy_alerts([]); } + + // -------------- Alerts + + this.newAlerts.forEach(alert => { + if (alert === undefined) { + console.error('Why?? alert = ', alert); + return; + } + console.debug('considering newAlert', alert); + const existingAlert = copy.copy_alerts().find(existing => alert.id() === existing.id()); + if (existingAlert) { + console.debug('updating pending newAlert', existingAlert); + existingAlert.isnew(true); + existingAlert.copy(copy.id()); + } else { + const newAlert = this.idl.clone(alert); + newAlert.id(null); + newAlert.isnew(true); + newAlert.copy(copy.id()); + copy.copy_alerts( + copy.copy_alerts().concat(newAlert) + ); + } + copy.ischanged(true); + }); + + this.changedAlerts.forEach(changedAlert => { + if (changedAlert === undefined) { + console.error('Why?? changedAlert = ', changedAlert); + return; + } + console.debug('considering changedAlert', changedAlert); + let existingAlert = null; + if ('originalAlertIds' in changedAlert) { // ProxyAlert + console.debug('batch mode proxy'); + existingAlert = copy.copy_alerts().find(existing => changedAlert.originalAlertIds.includes(existing.id())); + } else { + console.debug('single-item mode not-a-proxy'); + existingAlert = copy.copy_alerts().find(existing => changedAlert.id() === existing.id()); + } + if (existingAlert) { + existingAlert.alert_type(changedAlert.alert_type()); + existingAlert.temp(changedAlert.temp()); + existingAlert.note(changedAlert.note()); + existingAlert.ack_time(changedAlert.ack_time()); + existingAlert.ack_staff(changedAlert.ack_staff()); + if (! (existingAlert.isnew() ?? false)) { existingAlert.ischanged(true); } + console.debug('changing existing', existingAlert); + } else { + // I forget how this might happen, but just in case + console.error('converting changedAlert to newAlert', changedAlert); + const newAlert = this.idl.clone(changedAlert); + // newAlert.id(null); + newAlert.isnew(true); + newAlert.copy(copy.id()); + copy.copy_alerts( + copy.copy_alerts().concat(newAlert) + ); + } + copy.ischanged(true); + }); + + copy.copy_alerts().forEach( c => c.isdeleted(false) ); // to accommodate undeletes + this.deletedAlerts.forEach(deletedAlert => { + if (deletedAlert === undefined) { + console.error('Why?? deletedAlert = ', deletedAlert); + return; + } + console.debug('considering deletedAlert', deletedAlert); + let existingAlert = null; + if ('originalAlertIds' in deletedAlert) { // ProxyAlert + existingAlert = copy.copy_alerts().find(existing => deletedAlert.originalAlertIds.includes(existing.id())); + } else { + existingAlert = copy.copy_alerts().find(existing => deletedAlert.id() === existing.id()); + } + if (existingAlert) { + existingAlert.isdeleted(true); + } else { + console.warn('Could not find existing alert to match deleted alert'); + } + copy.ischanged(true); + }); + + // what are we doing here? + const counts = { 'new': 0, 'changed': 0, 'deleted': 0 }; + copy.copy_alerts().forEach(a => { + counts.new += Number( a.isnew() ?? false); // who knew you could cast bools into numbers? + counts.changed += Number( a.ischanged() ?? false); // but why do our methods here sometime return undefined? bleh + counts.deleted += Number( a.isdeleted() ?? false); + }); + console.debug('breakdown: ', { new: counts.new, changed: counts.changed, deleted: counts.deleted }); + } + + updateInMemoryCopiesWithNotes() { + console.debug('updateInMemoryCopiesWithNotes', this); + this.copyList().forEach(copy => { + this.updateInMemoryCopyWithNotes(copy); + }); + } + + updateInMemoryCopyWithNotes(copy) { + console.debug('updateInMemoryCopyWithNotes, considering copy', copy.id(), copy); + console.debug('with this.newNotes', this.newNotes.length, this.newNotes); + console.debug('with this.changedNotes', this.changedNotes.length, this.changedNotes); + console.debug('with this.deletedNotes', this.deletedNotes.length, this.deletedNotes); + + // Initialize array if needed + if (!copy.notes()) { copy.notes([]); } + + // -------------- Notes + + this.newNotes.forEach(note => { + if (note === undefined) { + console.error('Why?? note = ', note); + return; + } + console.debug('considering newNote', note); + const existingNote = copy.notes().find(existing => note.id() === existing.id()); + if (existingNote) { + console.debug('updating pending newNote', existingNote); + existingNote.isnew(true); + existingNote.owning_copy(copy.id()); + } else { + const newNote = this.idl.clone(note); + newNote.id(null); + newNote.isnew(true); + newNote.owning_copy(copy.id()); + copy.notes( + copy.notes().concat(newNote) + ); + } + copy.ischanged(true); + }); + + this.changedNotes.forEach(changedNote => { + if (changedNote === undefined) { + console.error('Why?? changedNote = ', changedNote); + return; + } + console.debug('considering changedNote', changedNote); + let existingNote = null; + if ('originalNoteIds' in changedNote) { // ProxyNote + console.debug('batch mode proxy'); + existingNote = copy.notes().find(existing => changedNote.originalNoteIds.includes(existing.id())); + } else { + console.debug('single-item mode not-a-proxy'); + existingNote = copy.notes().find(existing => changedNote.id() === existing.id()); + } + if (existingNote) { + existingNote.pub(changedNote.pub()); + existingNote.title(changedNote.title()); + existingNote.value(changedNote.value()); + if (! (existingNote.isnew() ?? false)) { existingNote.ischanged(true); } + console.debug('changing existing', existingNote); + } else { + // I forget how this might happen, but just in case + console.error('converting changedNote to newNote', changedNote); + const newNote = this.idl.clone(changedNote); + // newNote.id(null); + newNote.isnew(true); + newNote.owning_copy(copy.id()); + copy.notes( + copy.notes().concat(newNote) + ); + } + copy.ischanged(true); + }); + + copy.notes().forEach( c => c.isdeleted(false) ); // to accommodate undeletes + this.deletedNotes.forEach(deletedNote => { + if (deletedNote === undefined) { + console.error('Why?? deletedNote = ', deletedNote); + return; + } + console.debug('considering deletedNote', deletedNote); + let existingNote = null; + if ('originalNoteIds' in deletedNote) { // ProxyNote + existingNote = copy.notes().find(existing => deletedNote.originalNoteIds.includes(existing.id())); + } else { + existingNote = copy.notes().find(existing => deletedNote.id() === existing.id()); + } + if (existingNote) { + existingNote.isdeleted(true); + } else { + console.warn('Could not find existing note to match deleted note'); + } + copy.ischanged(true); + }); + + // what are we doing here? + const counts = { 'new': 0, 'changed': 0, 'deleted': 0 }; + copy.notes().forEach(a => { + counts.new += Number( a.isnew() ?? false); // who knew you could cast bools into numbers? + counts.changed += Number( a.ischanged() ?? false); // but why do our methods here sometime return undefined? bleh + counts.deleted += Number( a.isdeleted() ?? false); + }); + console.debug('breakdown: ', { new: counts.new, changed: counts.changed, deleted: counts.deleted }); + } + + updateInMemoryCopiesWithTags() { + console.debug('updateInMemoryCopiesWithTags', this); + this.copyList().forEach(copy => { + this.updateInMemoryCopyWithTags(copy); + }); + } + + updateInMemoryCopyWithTags(copy) { + console.debug('considering copy', copy.id(), copy); + console.debug('with this.newTagMaps', this.newTagMaps.length, this.newTagMaps); + console.debug('with this.changedTagMaps', this.changedTagMaps.length, this.changedTagMaps); + console.debug('with this.deletedTagMaps', this.deletedTagMaps.length, this.deletedTagMaps); + + // Initialize array if needed + if (!copy.tags()) { copy.tags([]); } + + // -------------- Tag Maps + + this.newTagMaps.forEach(tagMap => { + if (tagMap === undefined) { + console.error('Why?? tagMap = ', tagMap); + return; + } + console.debug('considering newTagMap', tagMap); + const existingTagMap = copy.tags().find(existing => tagMap.id() === existing.id()); + const collidingTagMaps = copy.tags().filter( + colliding => this.idl.pkeyValue(tagMap.tag()) === this.idl.pkeyValue(colliding.tag()) + ); + if (existingTagMap) { + console.debug('updating pending newTagMap', existingTagMap); + existingTagMap.isnew(true); + existingTagMap.copy(copy.id()); + copy.ischanged(true); + } else if (collidingTagMaps.length > 0) { + console.log(`Copy with ID ${ copy.id() }, already has a tag map for this tag; keeping it.`); + } else { + const newTagMap = this.idl.clone(tagMap); + newTagMap.id(null); + newTagMap.isnew(true); + newTagMap.copy(copy.id()); + copy.tags( + copy.tags().concat(newTagMap) + ); + copy.ischanged(true); + } + }); + + this.changedTagMaps.forEach(changedTagMap => { + if (changedTagMap === undefined) { + console.error('Why?? changedTagMap = ', changedTagMap); + return; + } + console.debug('considering changedTagMap', changedTagMap); + let existingTagMap = null; + if ('originalTagMapIds' in changedTagMap) { // ProxyTagMap + console.debug('batch mode proxy'); + existingTagMap = copy.tags().find(existing => changedTagMap.originalTagMapIds.includes(existing.id())); + } else { + console.debug('single-item mode not-a-proxy'); + existingTagMap = copy.tags().find(existing => changedTagMap.id() === existing.id()); + } + if (existingTagMap) { + existingTagMap.tag(changedTagMap.tag()); + if (! (existingTagMap.isnew() ?? false)) { existingTagMap.ischanged(true); } + console.debug('changing existing', existingTagMap); + } else { + // I forget how this might happen, but just in case + console.error('converting changedTagMap to newTagMap', changedTagMap); + const newTagMap = this.idl.clone(changedTagMap); + // newTagMap.id(null); + newTagMap.isnew(true); + newTagMap.copy(copy.id()); + copy.tags( + copy.tags().concat(newTagMap) + ); + } + copy.ischanged(true); + }); + + copy.tags().forEach( c => c.isdeleted(false) ); // to accommodate undeletes + this.deletedTagMaps.forEach(deletedTagMap => { + if (deletedTagMap === undefined) { + console.error('Why?? deletedTagMap = ', deletedTagMap); + return; + } + console.debug('considering deletedTagMap', deletedTagMap); + let existingTagMap = null; + if ('originalTagMapIds' in deletedTagMap) { // ProxyTagMap + existingTagMap = copy.tags().find(existing => deletedTagMap.originalTagMapIds.includes(existing.id())); + } else { + existingTagMap = copy.tags().find(existing => deletedTagMap.id() === existing.id()); + } + if (existingTagMap) { + existingTagMap.isdeleted(true); // should be redundant here, but just in case + const matchingTagMaps = copy.tags().filter( + matching => this.idl.pkeyValue(existingTagMap.tag()) === this.idl.pkeyValue(matching.tag()) + ); + if (matchingTagMaps.length > 1) { + console.log('Deleting multiple tag maps with the same tag', matchingTagMaps); + } + matchingTagMaps.forEach( tm => { + tm.isdeleted(true); + }); + } else { + console.warn('Could not find existing tag to match deleted tag'); + } + copy.ischanged(true); + }); + + // what are we doing here? + const counts = { 'new': 0, 'changed': 0, 'deleted': 0 }; + copy.tags().forEach(a => { + counts.new += Number( a.isnew() ?? false); // who knew you could cast bools into numbers? + counts.changed += Number( a.ischanged() ?? false); // but why do our methods here sometime return undefined? bleh + counts.deleted += Number( a.isdeleted() ?? false); + }); + console.debug('breakdown: ', { new: counts.new, changed: counts.changed, deleted: counts.deleted }); + } } diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html index 4f71cda166..b400a0aec6 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/copies.component.html @@ -19,7 +19,7 @@ href="/eg/staff/cat/item/{{copy.id}}" i18n>View</a> <ng-container *ngIf="context.editable(copy)"> | <a class="ps-1" target="_blank" attr.aria-describedby="copy-barcode-{{copy.id}}" - routerLink="/staff/cat/volcopy/attrs/item/{{copy.id}}" i18n>Edit</a> + routerLink="/staff/cat/volcopy/attrs/item/{{copy.id}}" [queryParams]="{record_id: recId}" i18n>Edit</a> </ng-container> </div> </ng-template> diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts index 6abef94e20..ab66e2ddb8 100644 --- a/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/catalog/record/holdings.component.ts @@ -256,6 +256,7 @@ export class HoldingsMaintenanceComponent implements OnInit { } ngOnInit() { + // console.debug('HoldingsComponent, ngOnInit(), this', this); this.initDone = true; this.broadcaster.listen('eg.holdings.update').subscribe(data => { @@ -312,6 +313,7 @@ export class HoldingsMaintenanceComponent implements OnInit { } hardRefresh() { + // console.debug('HoldingsComponent, hardRefresh()'); this.renderFromPrefs = true; this.refreshHoldings = true; this.initHoldingsTree(); @@ -727,7 +729,9 @@ export class HoldingsMaintenanceComponent implements OnInit { // Which copies in the grid are selected. selectedCopyIds(rows: HoldingsEntry[], skipStatus?: number): number[] { - return this.selectedCopies(rows, skipStatus).map(c => Number(c.id())); + const result = this.selectedCopies(rows, skipStatus).map(c => Number(c.id())); + // console.debug('Holdings: selectedCopyIds; rows, result', rows, result); + return result; } selectedVolIds(rows: HoldingsEntry[]): number[] { @@ -738,10 +742,12 @@ export class HoldingsMaintenanceComponent implements OnInit { selectedCopies(rows: HoldingsEntry[], skipStatus?: number): IdlObject[] { let copyRows = rows.filter(r => Boolean(r.copy)).map(r => r.copy); + // console.debug('Holdings: selectedCopies(); rows, copyRows pre-status filter', rows, copyRows); if (skipStatus) { copyRows = copyRows.filter( c => Number(c.status().id()) !== Number(skipStatus)); } + // console.debug('Holdings: selectedCopies(); rows, copyRows post-status filter', rows, copyRows); return copyRows; } @@ -954,15 +960,16 @@ export class HoldingsMaintenanceComponent implements OnInit { openItemAlerts(rows: HoldingsEntry[]) { const copyIds = this.selectedCopyIds(rows); + // console.debug('Holdings: openItemAlerts; rows, copyIds', rows, copyIds); if (copyIds.length === 0) { return; } this.copyAlertsDialog.copyIds = copyIds; + this.copyAlertsDialog.copies = []; + this.copyAlertsDialog.clearPending(); this.copyAlertsDialog.open({size: 'lg'}).subscribe( changes => { - if (!changes) { return; } - if (changes.newAlerts.length > 0 || changes.changedAlerts.length > 0) { - this.hardRefresh(); - } + // console.debug('HoldingsComponent: copyAlertsDialog, changes?', changes); + this.hardRefresh(); } ); } @@ -972,11 +979,12 @@ export class HoldingsMaintenanceComponent implements OnInit { if (copyIds.length === 0) { return; } this.copyTagsDialog.copyIds = copyIds; + this.copyTagsDialog.copies = []; + this.copyTagsDialog.clearPending(); this.copyTagsDialog.open({size: 'lg'}).subscribe( changes => { - if (changes.newTags.length > 0 || changes.deletedMaps.length > 0) { - this.hardRefresh(); - } + // console.debug('HoldingsComponent: copyTagsDialog, changes?', changes); + this.hardRefresh(); } ); } @@ -986,12 +994,12 @@ export class HoldingsMaintenanceComponent implements OnInit { if (copyIds.length === 0) { return; } this.copyNotesDialog.copyIds = copyIds; + this.copyNotesDialog.copies = []; + this.copyNotesDialog.clearPending(); this.copyNotesDialog.open({size: 'lg'}).subscribe( changes => { - if (!changes) { return; } - if (changes.newNotes.length > 0 || changes.delNotes.length > 0) { - this.hardRefresh(); - } + // console.debug('HoldingsComponent: copyNotesDialog, changes?', changes); + this.hardRefresh(); } ); } diff --git a/Open-ILS/src/eg2/src/app/staff/circ/checkin/checkin.component.ts b/Open-ILS/src/eg2/src/app/staff/circ/checkin/checkin.component.ts index 8e6e660d99..7b0c195dae 100644 --- a/Open-ILS/src/eg2/src/app/staff/circ/checkin/checkin.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/circ/checkin/checkin.component.ts @@ -304,7 +304,7 @@ export class CheckinComponent implements OnInit, AfterViewInit { if (copyIds.length === 0) { return; } this.copyAlertsDialog.copyIds = copyIds; - this.copyAlertsDialog.mode = 'create'; + // this.copyAlertsDialog.mode = 'create'; this.copyAlertsDialog.open({size: 'lg'}).subscribe(); } @@ -313,7 +313,7 @@ export class CheckinComponent implements OnInit, AfterViewInit { if (copyIds.length === 0) { return; } this.copyAlertsDialog.copyIds = copyIds; - this.copyAlertsDialog.mode = 'manage'; + // this.copyAlertsDialog.mode = 'manage'; this.copyAlertsDialog.open({size: 'lg'}).subscribe(); } diff --git a/Open-ILS/src/eg2/src/app/staff/circ/patron/checkout.component.ts b/Open-ILS/src/eg2/src/app/staff/circ/patron/checkout.component.ts index a4c5ab2c6d..bba919227b 100644 --- a/Open-ILS/src/eg2/src/app/staff/circ/patron/checkout.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/circ/patron/checkout.component.ts @@ -309,10 +309,9 @@ export class CheckoutComponent implements OnInit, AfterViewInit { if (copyIds.length === 0) { return; } this.copyAlertsDialog.copyIds = copyIds; - this.copyAlertsDialog.mode = mode; this.copyAlertsDialog.open({size: 'lg'}).subscribe( modified => { - if (modified && modified.newAlerts.length > 0) { + if (modified && modified.newThings.length > 0) { rows.forEach(row => row.copyAlertCount++); this.checkoutsGrid.reload(); } diff --git a/Open-ILS/src/eg2/src/app/staff/circ/renew/renew.component.ts b/Open-ILS/src/eg2/src/app/staff/circ/renew/renew.component.ts index 9d9a353d6d..52f3b7e97e 100644 --- a/Open-ILS/src/eg2/src/app/staff/circ/renew/renew.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/circ/renew/renew.component.ts @@ -225,7 +225,6 @@ export class RenewComponent implements OnInit, AfterViewInit { if (copyIds.length === 0) { return; } this.copyAlertsDialog.copyIds = copyIds; - this.copyAlertsDialog.mode = 'create'; this.copyAlertsDialog.open({size: 'lg'}).subscribe(); } @@ -234,7 +233,6 @@ export class RenewComponent implements OnInit, AfterViewInit { if (copyIds.length === 0) { return; } this.copyAlertsDialog.copyIds = copyIds; - this.copyAlertsDialog.mode = 'manage'; this.copyAlertsDialog.open({size: 'lg'}).subscribe(); } diff --git a/Open-ILS/src/eg2/src/app/staff/share/circ/grid.component.ts b/Open-ILS/src/eg2/src/app/staff/share/circ/grid.component.ts index b7b193ddbd..6042775575 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/circ/grid.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/circ/grid.component.ts @@ -262,7 +262,7 @@ export class CircGridComponent implements OnInit { if (copyIds.length === 0) { return; } this.copyAlertsDialog.copyIds = copyIds; - this.copyAlertsDialog.mode = mode; + // this.copyAlertsDialog.mode = mode; this.copyAlertsDialog.open({size: 'lg'}).subscribe( modified => { if (modified) { diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.css b/Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.css new file mode 100644 index 0000000000..49b8e8875c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.css @@ -0,0 +1,23 @@ +.card:has(.edit-buttons) { + background-color: var(--bs-body-bg-highlight); + display: block; + padding: 0.5rem 1rem; +} + +.card:has(.edit-buttons) .card-header { + margin-block-end: 0.5rem; +} + +.card .edit-buttons { + display: flex; + flex-wrap: wrap; + margin-block-start: 0.75rem; +} + +.card-body fieldset label > div { + display: inline; +} + +.card-body fieldset { + margin-bottom: 0.5rem; +} \ No newline at end of file diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.html index 8a1752c527..f939be81a4 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.html @@ -1,72 +1,97 @@ +<ng-template #showValues let-key="key" let-displayAs="displayAs"> + <div class="term"> + <ng-container *ngIf="displayAs === 'bool'"> + <ng-container *ngIf="valueIsUnset(key); else defaultBool"> + <span class="value" i18n><Unset></span> + </ng-container> + <ng-template #defaultBool> + <span class="value" *ngIf="key === 't'" i18n>Yes</span> + <span class="value" *ngIf="key === 'f'" i18n>No</span> + </ng-template> + </ng-container> + <ng-container *ngIf="displayAs === 'currency'"> + <ng-container + *ngIf="valueIsUnset(key); else defaultCurrency"> + <span class="value" i18n><Unset></span> + </ng-container> + <ng-template #defaultCurrency>{{key | currency}}</ng-template> + </ng-container> + <ng-container *ngIf="displayAs !== 'bool' && displayAs !== 'currency'"> + <ng-container + *ngIf="valueIsUnset(key); else default"> + <span class="value" i18n><Unset></span> + </ng-container> + <ng-template #default><span class="value">{{key}}</span></ng-template> + </ng-container> + </div> +</ng-template> -<div class="border rounded m-1"> - <div class="fw-bold header p-2 d-flex" i18n> - {{label}} <span *ngIf="hasChanged" class="text-danger">*</span> - <ng-container *ngIf="bulky()"> - <div class="flex-1"></div> - <button (click)="expanded = true" *ngIf="!expanded" type="button" - aria-label="Expand" i18n-aria-label title="Expand" i18n-title> - <span class="material-icons" aria-hidden="true">unfold_more</span> +<!-- hush, eslint, we have focusable click handlers on the <button> --> +<!-- eslint-disable @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/accessibility-interactive-supports-focus --> +<div class="card" [ngClass]="{ + 'has-changes': !!hasChanged, + 'required': !!(valueRequired && !templateOnlyMode), + 'required-not-met': !!(valueRequired && requiredNotMet && !templateOnlyMode), + 'required-met': !!(valueRequired && !requiredNotMet && !templateOnlyMode), + 'unset': !!aValueIsUnset +}" (click)="(readOnly || editing) ? null : enterEditMode()"> + <div class="card-header"> + <h5 id="label-{{editInputDomId}}" *ngIf="readOnly || editing" class="readonly field-heading" i18n> + {{label}} + </h5> + <h5 id="label-{{editInputDomId}}" class="field-heading" *ngIf="!readOnly && !editing"> + <button type="button" class="btn-link edit-toggle" (click)="enterEditMode()" i18n> + {{label}} </button> - <button (click)="expanded = false" *ngIf="expanded" type="button" - aria-label="Condense" i18n-aria-label title="Condense" i18n-title> - <span class="material-icons" aria-hidden="true">unfold_less</span> - </button> - </ng-container> + </h5> </div> - <div tabindex="0" class="p-2" *ngIf="!editing || multiValue()" - (click)="enterEditMode()" (keyup.enter)="enterEditMode()" - [ngClass]="{'has-changes': hasChanged, 'bg-warning': warnOnRequired()}"> - <div class="d-flex" - *ngFor="let count of labelCounts | keyvalue; let idx = index"> - <ng-container *ngIf="!expanded && !editing && idx === defaultDisplayCount"> - <span class="text-info" i18n>...</span> - </ng-container> - <ng-container *ngIf="expanded || editing || idx < defaultDisplayCount"> - <ng-container *ngIf="editing"> - <div class="ms-4 me-2"> - <input type="checkbox" class="form-check-input" - [(ngModel)]="editValues[count.key]"/> - </div> - </ng-container> - <div class="flex-1"> - <ng-container *ngIf="displayAs === 'bool'"> - <ng-container *ngIf="valueIsUnset(count.key); else defaultBool"> - <span i18n><Unset></span> + <div class="card-body" *ngIf="editing"> + <fieldset *ngIf="multiValue()" [attr.aria-labelledby]="'label-'+editInputDomId"> + <ul class="list-unstyled"> + <li *ngFor="let count of labelCounts | keyvalue; let idx = index" class="form-check"> + <input type="checkbox" class="form-check-input" id="{{editInputDomId}}-{{count.key}}" + [(ngModel)]="editValues[count.key]" /> + <label for="{{editInputDomId}}-{{count.key}}"> + <ng-container *ngTemplateOutlet="showValues; context: {key: count.key, displayAs: displayAs}"> </ng-container> - <ng-template #defaultBool> - <span *ngIf="count.key === 't'" i18n>Yes</span> - <span *ngIf="count.key === 'f'" i18n>No</span> - </ng-template> - </ng-container> - <ng-container *ngIf="displayAs === 'currency'"> - <ng-container - *ngIf="valueIsUnset(count.key); else defaultCurrency"> - <span i18n><Unset></span> - </ng-container> - <ng-template #defaultCurrency>{{count.key | currency}}</ng-template> - </ng-container> - <ng-container *ngIf="displayAs !== 'bool' && displayAs !== 'currency'"> - <ng-container - *ngIf="valueIsUnset(count.key); else default"> - <span i18n><Unset></span> - </ng-container> - <ng-template #default>{{count.key}}</ng-template> + <div *ngIf="!templateOnlyMode && multiValue()" class="def numeric"> + ({{count.value}}) + </div> + </label> + </li> + </ul> + </fieldset> + + <ng-container *ngTemplateOutlet="editTemplate"></ng-container> + + <div class="edit-buttons"> + <button type="submit" class="btn btn-sm btn-primary" (click)="save($event)" i18n>Apply</button> + <button type="button" class="btn btn-sm btn-normal ms-1" (click)="cancel($event)" i18n>Cancel</button> + <button type="button" class="btn btn-sm btn-destroy ms-auto" (click)="clear($event)" i18n>Clear</button> + </div> + </div> + <div class="card-body" *ngIf="!editing"> + <div class="dl-grid"> + <ng-container *ngFor="let count of labelCounts | keyvalue; let idx = index"> + <ng-container *ngIf="expanded || idx < defaultDisplayCount"> + + + <ng-container *ngTemplateOutlet="showValues; context: {key: count.key, displayAs: displayAs}"> </ng-container> - </div> - <div class="ps-1 border-start" i18n> - {count.value, plural, =1 {1 copy} other {{{count.value}} copies}} - </div> + <div *ngIf="!templateOnlyMode && multiValue()" class="def numeric"> + {{count.value}} + </div> + </ng-container> + </ng-container> </div> - </div> - <ng-container *ngIf="editing"> - <ng-container *ngTemplateOutlet="editTemplate"></ng-container> - <div class="mt-1"> - <button type="button" class="btn btn-outline-dark" (click)="save()" i18n>Apply</button> - <button type="button" class="btn btn-outline-dark ms-1" (click)="cancel()" i18n>Cancel</button> - <button type="button" class="btn btn-outline-dark ms-1" (click)="clear()" i18n>Clear</button> + <div class="ms-auto text-end" *ngIf="bulky()"> + <button (click)="expanded = true" *ngIf="!expanded" type="button" class="btn-link" i18n> + Show all + </button> + <button (click)="expanded = false" *ngIf="expanded" type="button" class="btn-link" i18n> + Show less + </button> </div> - </ng-container> + </div> </div> - diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.ts index 573f415467..98fe28c478 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.ts @@ -17,13 +17,10 @@ export interface BatchChangeSelection { @Component({ selector: 'eg-batch-item-attr', templateUrl: 'batch-item-attr.component.html', - styles: [ - '.header { background-color: var(--batch-item-attr-header-bg); }', - '.has-changes { background-color: var(--success-text-light); }' - ] + styleUrls: ['batch-item-attr.component.css', '../../cat/volcopy/copy-attrs.component.css'] }) -export class BatchItemAttrComponent { +export class BatchItemAttrComponent implements OnInit { // Main display label, e.g. "Circulation Modifier" @Input() label: string; @@ -50,8 +47,13 @@ export class BatchItemAttrComponent { // Display only @Input() readOnly = false; + // when used in a template admin context; expect to use for styling + @Input() templateOnlyMode = false; + // Warn the user when a required field has an empty value @Input() valueRequired = false; + requiredNotMet = false; + aValueIsUnset = false; // If true, a value of '' is considered unset for display and // valueRequired purposes. @@ -80,21 +82,52 @@ export class BatchItemAttrComponent { constructor() {} - save() { + ngOnInit() { + this.checkValuesForCSS(); + } + + save($event?: Event) { + if ($event) { + $event.preventDefault(); + $event.stopPropagation(); + } this.hasChanged = true; this.editing = false; + this.checkValuesForCSS(); this.changesSaved.emit(this.editValues); + this.focusLabel(); } - cancel() { + cancel($event?: Event) { + if ($event) { + $event.preventDefault(); + $event.stopPropagation(); + } this.editing = false; + this.checkValuesForCSS(); this.changesCanceled.emit(); + this.focusLabel(); } - clear() { + clear($event?: Event) { + if ($event) { + $event.preventDefault(); + $event.stopPropagation(); + } this.hasChanged = true; this.editing = false; + this.checkValuesForCSS(); this.valueCleared.emit(); + this.focusLabel(); + } + + focusLabel() { + setTimeout(() => { + // fieldset input[type="radio"]:checked for yes/no; label.edit-toggle for all others + // eslint-disable-next-line max-len + const input = document.querySelector(`.card:has(#label-${this.editInputDomId}) .edit-toggle, .card:has(#label-${this.editInputDomId}) fieldset input[type="radio"]:checked`) as HTMLElement; + input?.focus(); + }); } bulky(): boolean { @@ -105,10 +138,26 @@ export class BatchItemAttrComponent { return Object.keys(this.labelCounts).length > 1; } - // True if a value is required and any value exists that's unset. + checkValuesForCSS() { + this.aValueIsUnset = this.testAllValuesForUnset(); + this.requiredNotMet = !!(this.valueRequired && this.aValueIsUnset && !this.templateOnlyMode); + /* console.debug('checkValuesForCSS for ' + this.label, { + 'has-changes': !!this.hasChanged, + 'required': !!(this.valueRequired && !this.templateOnlyMode), + 'required-not-met': !!(this.valueRequired && this.requiredNotMet && !this.templateOnlyMode), + 'requiredNotMet': !!this.requiredNotMet, + 'required-met': !!(this.valueRequired && !this.requiredNotMet && !this.templateOnlyMode), + 'unset': !!this.aValueIsUnset, + 'templateOnlyMode': !!this.templateOnlyMode + });*/ + } + warnOnRequired(): boolean { - if (!this.valueRequired) { return false; } + this.checkValuesForCSS(); + return this.requiredNotMet; + } + testAllValuesForUnset(): boolean { return Object.keys(this.labelCounts) .filter(key => this.valueIsUnset(key)).length > 0; } diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alert-manager.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alert-manager.component.ts index 72363042eb..13392e8dc8 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alert-manager.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alert-manager.component.ts @@ -143,11 +143,10 @@ export class CopyAlertManagerDialogComponent extends DialogComponent { openItemAlerts() { const copyIds = [ this.alerts[0].copy() ]; this.copyAlertsDialog.copyIds = copyIds; - this.copyAlertsDialog.mode = 'manage'; this.copyAlertsDialog.open({size: 'lg'}).subscribe( changes => { if (!changes) { return; } - if (changes.newAlerts.length > 0 || changes.changedAlerts.length > 0) { + if (changes.newThings.length > 0 || changes.changedThings.length > 0) { this.newAlertsAdded = true; } } diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.html index dfb7c568db..0922f464e5 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.html @@ -1,121 +1,186 @@ -<eg-string #successMsg text="Successfully Modified Item Alerts" i18n-text></eg-string> -<eg-string #errorMsg text="Failed To Modify Item Alerts" i18n-text></eg-string> +<eg-string #successMsg [text]="successMessage" i18n-text></eg-string> +<eg-string #errorMsg [text]="errorMessage" i18n-text></eg-string> <ng-template #dialogContent> - <div class="modal-header"> - <h4 class="modal-title"> - <ng-container *ngIf="mode === 'create'"> - <span i18n>Adding alerts for {{copyIds.length}} item(s).</span> - </ng-container> - <ng-container *ngIf="mode === 'manage'"> - <span *ngIf="!inBatch()" i18n>Managing alerts for item {{copies.length ? copies[0].barcode() : ''}}</span> - <span *ngIf="inBatch()" i18n>Managing alerts in common for {{copyIds.length}} item(s).</span> - </ng-container> - <span i18n></span> - </h4> - <button type="button" class="btn-close btn-close-white" - i18n-aria-label aria-label="Close dialog" (click)="close()"></button> - </div> - <div class="modal-body p-4 form-validated"> - <div class="row mt-2 p-2 rounded border border-success"> - <div class="col-lg-4"> - <eg-combobox [entries]="alertTypes" - i18n-placeholder placeholder="Select Alert Type..." - domId="item-alert-type" - [selectedId]="newAlert.alert_type() || defaultAlertType" - [mandatory]="true" - (onChange)="newAlert.alert_type($event ? $event.id : null)"> - </eg-combobox> +<eg-copy-things-dialog + [thingType]="thingType" + [copies]="copies" + [copyIds]="copyIds" + [batchWarningMessage]="batchWarningMessage" + [inBatch]="inBatch.bind(this)" + [onClose]="close.bind(this)" + [onApplyChanges]="applyChanges.bind(this)"> + + <h4 class="mt-2 border-bottom" i18n>New Item Alert</h4> + + <!-- New alert form --> + <form class="row mt-3 px-0" id="new-alert-form"> + <!-- Alert Type --> + <div class="col-lg-4"> + <label for="item-alert-type" class="form-label" i18n>Alert Type</label> + <eg-combobox [entries]="activeAlertTypes" + [name]="'item-alert-type'" #newAlertTitle + domId="item-alert-type" [ariaDescribedby]="'item-alert-type-error'" + [selectedId]="newAlert.alert_type() || defaultAlertType" + [mandatory]="true" [ngbAutofocus]="true" + (onChange)="newAlert.alert_type($event ? $event.id : null)"> + </eg-combobox> + <div class="invalid-feedback" id="item-alert-type-error" hidden> + <span class="badge badge-danger" i18n>Alert type is required</span> </div> - <div class="col-lg-5"> - <textarea class="form-control" rows="2" - i18n-placeholder placeholder="New Alert Note..." - (ngModelChange)="newAlert.note($event)" [ngModel]="newAlert.note()"> - </textarea> + </div> + + <!-- Alert Note --> + <div class="col-lg-5"> + <label for="item-alert-note" class="form-label" i18n>Alert Note</label> + <textarea class="form-control" rows="2" id="item-alert-note" name="item-alert-note" + (ngModelChange)="newAlert.note($event)" + [ngModel]="newAlert.note()"> + </textarea> + </div> + + <!-- Alert Options --> + <div class="col-lg-3 d-flex flex-wrap align-items-baseline justify-content-end"> + + <!-- Temporary flag --> + <div class="form-check mb-2 me-auto"> + <input class="form-check-input" type="checkbox" + [ngModel]="newAlert.temp() === 't' || newAlert.temp() === true" + (ngModelChange)="newAlert.temp($event ? 't' : 'f')" + id="new-alert-temporary" name="new-alert-temporary"> + <label class="form-label form-check-label" + for="new-alert-temporary" i18n> + Temporary? + </label> </div> - <div class="col-lg-3"> - <div class="d-flex flex-column"> - <div class="form-check"> - <input class="form-check-input" type="checkbox" - [ngModel]="newAlert.temp() === 't'" - (ngModelChange)="newAlert.temp($event ? 't' : 'f')" - id="new-alert-temporary"> - <label class="form-label form-check-label" for="new-alert-temporary" i18n> - Temporary? - </label> - </div> - <div class="pt-2"> - <button type="button" class="btn btn-success" (click)="addNew()" i18n> - Add New - </button> - </div> - </div> + + <!-- Add button --> + <button type="button" class="btn btn-success" + (click)="addNew()" i18n> + Add New + </button> + + </div> + </form> + + +<!-- Pending alerts list --> +<ng-container *ngIf="newThings && newThings.length"> +<h4 class="mt-4 border-bottom" i18n>Pending Item Alerts</h4> +<ul class="list-group list-group-flush"> + <li class="list-group-item px-0 py-3" *ngFor="let alert of newThings; index as i"> + <h5 class="visually-hidden" id="pending-{{i}}" i18n>Pending Alert #{{i}}</h5> + <div class="row"> + <!-- Alert Type --> + <div class="col-lg-4"> + <eg-combobox [entries]="getCurrentAlertTypes(alert.alert_type())" + [selectedId]="alert.alert_type()" + i18n-placeholder placeholder="Alert Type..." + [disableEntries]="getDisabledAlertTypes(alert.alert_type())" + validateDisabledSelection + [mandatory]="true" + (onChange)="alert.alert_type($event ? $event.id : null);"> + </eg-combobox> + <span *ngIf="disabledAlertTypes.includes(alert.alert_type())" class="badge badge-warning" i18n>This alert type is inactive.</span> + </div> + + <!-- Alert Note --> + <div class="col-lg-5"> + <textarea class="form-control" rows="2" + i18n-placeholder placeholder="Alert Note..." + (ngModelChange)="alert.note($event);" + [ngModel]="alert.note()"> + </textarea> + </div> + <div class="col-lg-3 d-flex flex-wrap align-items-baseline justify-content-end"> + <!-- Temporary flag --> + <div class="form-check mb-2 me-auto"> + <input class="form-check-input" type="checkbox" + [ngModel]="alert.temp() === 't' || alert.temp() === true" + (ngModelChange)="alert.temp($event ? 't' : 'f')" + id="alert-temporary-{{i}}"> + <label class="form-label form-check-label" + for="alert-temporary-{{i}}" i18n> + Temporary? + </label> </div> + + <button type="button" class="btn-link btn-destroy" + (click)="removeAlert(alert)" i18n>Remove</button> </div> - <h4 class="mt-2" i18n *ngIf="newAlerts.length > 0">Pending New Alerts</h4> - <div class="row mt-2" *ngFor="let alert of newAlerts"> - <div class="col-lg-4">{{getAlertTypeLabel(alert)}}</div> - <div class="col-lg-5">{{alert.note()}}</div> - <div class="col-lg-3"> - <button type="button" class="btn btn-outline-danger" (click)="removeAlert(alert)" i18n> - Remove - </button> + + </div> + </li> +</ul> +</ng-container> + + <ng-container *ngIf="!templateOnlyMode"> + <h4 class="mt-4 border-bottom" i18n>Existing Item Alerts</h4> + + <!-- Existing alerts list --> + <ul class="list-group list-group-flush"> + <li class="list-group-item px-0 py-3" + *ngFor="let alert of inBatch() ? alertsInCommon : (copies.length ? copies[0].copy_alerts() : [])"> + <div *ngIf="!alert.isnew()" class="row"> <!-- filter out pending notes on subsequent dialog opens --> + <!-- Alert Type --> + <div class="col-lg-4"> + <eg-combobox [entries]="getCurrentAlertTypes(alert.alert_type())" + [selectedId]="alert.alert_type()" + i18n-placeholder placeholder="Alert Type..." + [disableEntries]="getDisabledAlertTypes(alert.alert_type())" + validateDisabledSelection + [mandatory]="true" + (onChange)="alert.alert_type($event ? $event.id : null); alert.ischanged(true)"> + </eg-combobox> + <span *ngIf="disabledAlertTypes.includes(alert.alert_type())" class="badge badge-warning" i18n>This alert type is inactive.</span> + + <!-- Creation date - only show in single mode --> + <div *ngIf="!inBatch() && !templateOnlyMode" class="ps-2 pt-2 text-muted" i18n> + Added: {{alert.create_time() | date:'shortDate'}} </div> </div> - <ng-container *ngIf="mode === 'manage'"> - <!-- eventually we'll always be in 'manage' mode --> - <!-- if not in batch, list all the pertinent alerts linked to the copy --> - <!-- if in batch, use the alertsInCommon variable instead for some proxy alerts --> - <div class="row mt-2" - *ngFor="let alert of inBatch() ? alertsInCommon : (copies.length ? copies[0].copy_alerts() : [])"> - <div class="col-lg-12 pb-2"><hr/></div> - <div class="col-lg-4"> - <eg-combobox [entries]="alertTypes" [selectedId]="alert.alert_type()" - i18n-placeholder placeholder="Alert Type..." - [mandatory]="true" - (onChange)="alert.alert_type($event ? $event.id : null); alert.ischanged(true)"> - </eg-combobox> - <div *ngIf="!inBatch()" class="ps-2 pt-2" i18n> - Added: {{alert.create_time() | date:'shortDate'}} - </div> - </div> - <div class="col-lg-5"> - <textarea class="form-control" rows="2" - i18n-placeholder placeholder="Alert Note..." - (ngModelChange)="alert.note($event); alert.ischanged(true)" - [ngModel]="alert.note()"> - </textarea> + <!-- Alert Note --> + <div class="col-lg-5"> + <textarea class="form-control" rows="2" + i18n-placeholder placeholder="Alert Note..." + (ngModelChange)="alert.note($event); alert.ischanged(true)" + [ngModel]="alert.note()"> + </textarea> + </div> + + <!-- Alert Options --> + <div class="col-lg-3"> + <div class="d-flex flex-wrap align-items-baseline justify-content-end"> + <!-- Temporary flag --> + <div class="form-check mb-2 me-auto"> + <input class="form-check-input" type="checkbox" + [ngModel]="alert.temp() === 't' || alert.temp() === true" + (ngModelChange)="alert.temp($event ? 't' : 'f'); alert.ischanged(true)" + id="alert-temporary-{{alert.id()}}"> + <label class="form-label form-check-label" + for="alert-temporary-{{alert.id()}}" i18n> + Temporary? + </label> </div> - <div class="col-lg-3"> - <div class="d-flex flex-column"> - <div class="form-check"> - <input class="form-check-input" type="checkbox" - [ngModel]="alert.temp() === 't'" - (ngModelChange)="alert.temp($event ? 't' : 'f'); alert.ischanged(true)" - id="alert-temporary-{{alert.id()}}"> - <label class="form-label form-check-label" for="alert-temporary-{{alert.id()}}" i18n> - Temporary? - </label> - </div> - <div class="form-check pt-2"> - <input class="form-check-input" type="checkbox" - [ngModel]="alert.ack_time() !== null" - (ngModelChange)="alert.ack_time($event ? 'now' : null); alert.ischanged(true)" - id="alert-temporary-{{alert.id()}}"> - <label class="form-label form-check-label" for="alert-temporary-{{alert.id()}}" i18n> - Clear? - </label> - </div> - </div> + + <!-- Acknowledgment --> + <div class="form-check mb-2 me-auto"> + <input class="form-check-input" type="checkbox" + [ngModel]="alert.ack_time() !== null" + (ngModelChange)="setAck(alert,$event);" + id="alert-acknowledged-{{alert.id()}}"> + <label class="form-label form-check-label" + for="alert-acknowledged-{{alert.id()}}" i18n> + Clear? + </label> </div> </div> - </ng-container> - </div> - <div class="modal-footer"> - <button type="button" class="btn btn-secondary" - (click)="close()" i18n>Close</button> - <button type="button" class="btn btn-success me-2" - (click)="applyChanges()" i18n>Apply Changes</button> + </div> </div> + </li> +</ul> + </ng-container> + +</eg-copy-things-dialog> </ng-template> diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.ts index f5f504f7d1..6341a61b54 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-dialog.component.ts @@ -1,313 +1,372 @@ -import {Component, Input, ViewChild} from '@angular/core'; -import {Observable, throwError, from} from 'rxjs'; -import {switchMap} from 'rxjs/operators'; -import {IdlService, IdlObject} from '@eg/core/idl.service'; -import {ToastService} from '@eg/share/toast/toast.service'; -import {AuthService} from '@eg/core/auth.service'; -import {PcrudService} from '@eg/core/pcrud.service'; -import {OrgService} from '@eg/core/org.service'; -import {StringComponent} from '@eg/share/string/string.component'; -import {DialogComponent} from '@eg/share/dialog/dialog.component'; -import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap'; -import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; -import {ServerStoreService} from '@eg/core/server-store.service'; - -/** - * Dialog for managing copy alerts. - */ - -export interface CopyAlertsChanges { - newAlerts: IdlObject[]; - changedAlerts: IdlObject[]; +import { Component, Input, Directive, HostBinding } from '@angular/core'; +import { Observable } from 'rxjs'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ToastService } from '@eg/share/toast/toast.service'; +import { IdlObject, IdlService } from '@eg/core/idl.service'; +import { PcrudService } from '@eg/core/pcrud.service'; +import { OrgService } from '@eg/core/org.service'; +import { AuthService } from '@eg/core/auth.service'; +import { ServerStoreService } from '@eg/core/server-store.service'; +import {FormsModule, AbstractControl, NG_VALIDATORS, ValidationErrors, Validator, Validators, ValidatorFn} from '@angular/forms'; +import { ComboboxComponent, ComboboxEntry } from '@eg/share/combobox/combobox.component'; +import {VolCopyContext} from '@eg/staff/cat/volcopy/volcopy'; +import { + CopyThingsDialogComponent, + IThingObject, + IThingChanges, + IThingConfig +} from './copy-things-dialog.component'; + +export interface ICopyAlert extends IThingObject { + alert_type(val?: number): number; + temp(val?: boolean): boolean; + note(val?: string): string; + ack_time(val?: any): any; + ack_staff(val?: number): number; + copy(val?: number): number; + create_staff(val?: number): number; + create_time(val?: any): any; +} + +interface ProxyAlert extends ICopyAlert { + originalAlertIds: number[]; +} + +export interface ICopyAlertChanges extends IThingChanges<ICopyAlert> { + newThings: ICopyAlert[]; + changedThings: ICopyAlert[]; + deletedThings: ICopyAlert[]; } @Component({ selector: 'eg-copy-alerts-dialog', templateUrl: 'copy-alerts-dialog.component.html' }) +export class CopyAlertsDialogComponent extends + CopyThingsDialogComponent<ICopyAlert, ICopyAlertChanges> { -export class CopyAlertsDialogComponent - extends DialogComponent { - - @Input() copyIds: number[] = []; - copies: IdlObject[]; - alertIdMap: { [key: number]: any }; + protected thingType = 'alerts'; + protected successMessage = $localize`Successfully Modified Item Alerts`; + protected errorMessage = $localize`Failed To Modify Item Alerts`; + protected batchWarningMessage = + $localize`Note that items in batch do not share alerts directly. Displayed alerts represent matching alert groups.`; - mode: string; // create | manage - - // If true, no attempt is made to save new alerts to the - // database. It's assumed this takes place in the calling code. - @Input() inPlaceCreateMode = false; - - // This will not contain "real" alerts, but a deduped set of alert - // proxies in a batch context that match on alert_type, temp, note, - // and null/not-null for ack_time. - alertsInCommon: IdlObject[] = []; + context: VolCopyContext; + // Alert-specific properties alertTypes: ComboboxEntry[]; - newAlert: IdlObject; - newAlerts: IdlObject[]; - autoId = -1; - changesMade: boolean; - defaultAlertType = 1; // default default :-) + activeAlertTypes: ComboboxEntry[]; + disabledAlertTypes: any[] = []; + defaultAlertType = 1; + alertIdMap: { [key: number]: (IdlObject|ICopyAlert) } = {}; + + alertsInCommon: ICopyAlert[] = []; + newAlert: ICopyAlert; - @ViewChild('successMsg', { static: true }) private successMsg: StringComponent; - @ViewChild('errorMsg', { static: true }) private errorMsg: StringComponent; + alerts: IdlObject[] = []; constructor( - private modal: NgbModal, // required for passing to parent - private toast: ToastService, - private idl: IdlService, - private pcrud: PcrudService, - private org: OrgService, - private auth: AuthService, - private serverStore: ServerStoreService) { - super(modal); // required for subclassing - this.copyIds = []; - this.copies = []; - this.alertIdMap = {}; + modal: NgbModal, + toast: ToastService, + idl: IdlService, + pcrud: PcrudService, + org: OrgService, + auth: AuthService, + private serverStore: ServerStoreService + ) { + const config: IThingConfig<ICopyAlert> = { + idlClass: 'aca', + thingField: 'copy_alerts', + defaultValues: { + alert_type: 1, + create_staff: auth.user().id(), + temp: false + } + }; + super(modal, toast, idl, pcrud, org, auth, config); + this.newAlert = this.createNewThing(); + this.context = new VolCopyContext(); + this.context.org = org; // inject + this.context.idl = idl; // inject } - prepNewAlert(): IdlObject { - const newAlert = this.idl.create('aca'); - newAlert.alert_type(this.defaultAlertType); - newAlert.create_staff(this.auth.user().id()); - return newAlert; + protected createNewThing(): ICopyAlert { + const alert = super.createNewThing(); + alert.alert_type(this.defaultAlertType); + return alert; } - hasCopy(): Boolean { - return this.copies.length > 0; + public async initialize(): Promise<void> { + await this.getAlertTypes(); + await this.getDefaultAlertType(); + if (!this.newAlert) { + this.newAlert = this.createNewThing(); + } + // console.debug('CopyAlertsDialogComponent, initialize()'); + await super.initialize(); } - inBatch(): Boolean { - return this.copies.length > 1; + private async getAlertTypes(): Promise<void> { + if (this.alertTypes) { return; } + + const alertTypes = await this.pcrud.retrieveAll('ccat', + { + // active: true, + scope_org: this.org.ancestors(this.auth.user().ws_ou(), true), + order_by: {ccat: 'name'} + }, + { atomic: true } + ).toPromise(); + + this.disabledAlertTypes = alertTypes.filter(a => a.active() === 'f' || a.active() === false).map(a => a.id()); + this.activeAlertTypes = alertTypes.filter(a => a.active() === 't' || a.active() === true).map(a => ({ + id: a.id(), + label: a.name() + })); + + this.alertTypes = alertTypes.map(a => ({ + id: a.id(), + label: a.name() + })); } - /** - * Fetch the item/record, then open the dialog. - * Dialog promise resolves with true/false indicating whether - * the mark-damanged action occured or was dismissed. - */ - open(args: NgbModalOptions): Observable<CopyAlertsChanges> { - this.copies = []; - this.newAlerts = []; - this.newAlert = this.prepNewAlert(); - - if (this.copyIds.length === 0 && !this.inPlaceCreateMode) { - return throwError('copy ID required'); + private getCurrentAlertTypes(currentAlertType) { + if (this.disabledAlertTypes.includes(currentAlertType)) { + return this.activeAlertTypes.concat(this.alertTypes.find(t => t.id === currentAlertType)); } - // We're removing the distinction between 'manage' and 'create' - // modes and are implementing batch edit for existing alerts - // that match on alert_type, temp, note, and whether ack_time - // is set or not set. - this.mode = 'manage'; - - // Observerify data loading - const obs = from( - this.getAlertTypes() - .then(_ => this.getCopies()) - .then(_ => this.getCopyAlerts()) - .then(_ => this.getDefaultAlertType()) - .then(_ => { if (this.defaultAlertType) { this.newAlert.alert_type(this.defaultAlertType); } }) - ); - - // Return open() observable to caller - return obs.pipe(switchMap(_ => super.open(args))); + return this.activeAlertTypes; } - getAlertTypes(): Promise<any> { - if (this.alertTypes) { return Promise.resolve(); } + private getDisabledAlertTypes(currentAlertType) { + if (this.disabledAlertTypes.includes(currentAlertType)) { + return Array(currentAlertType); + } - return this.pcrud.retrieveAll('ccat', - { active: true, - scope_org: this.org.ancestors(this.auth.user().ws_ou(), true) - }, {atomic: true} - ).toPromise().then(alerts => { - this.alertTypes = alerts.map(a => ({id: a.id(), label: a.name()})); - }); + return; } - getCopies(): Promise<any> { - - // Avoid fetch if we're only adding notes to isnew copies. - const ids = this.copyIds.filter(id => id > 0); - if (ids.length === 0) { return Promise.resolve(); } + private async getDefaultAlertType(): Promise<void> { + const defaults = await this.serverStore.getItem('eg.cat.volcopy.defaults'); + if (defaults?.values?.thing_alert_type) { + this.defaultAlertType = defaults.values.thing_alert_type; + if (this.newAlert) { + this.newAlert.alert_type(this.defaultAlertType); + } + } + } - return this.pcrud.search('acp', {id: this.copyIds}, {}, {atomic: true}) - .toPromise().then(copies => { - this.copies = copies; - copies.forEach(c => c.copy_alerts([])); + protected async getThings(): Promise<void> { + if (this.copyIds.length === 0) { return; } + if (this.alerts.length > 0) { + // console.debug('already have alerts, trimming newThings from existing. newThings=', this.newThings); + this.copies.forEach( c => { + const newThingIds = this.newThings.map( aa => aa.id() ); + c.copy_alerts( + (c.copy_alerts() || []).filter( a => !newThingIds.includes(a.id()) ) + ); }); + return; + } // need to make sure this is cleared after a save. It is; the page reloads + + const query = { + copy: this.copyIds, + ack_time: null, + alert_type: this.alertTypes.map(a => a.id) + }; + this.alerts = await this.pcrud.search('aca', + query, + {}, + { atomic: true } + ).toPromise(); + + this.copies.forEach(c => c.copy_alerts([])); + this.alerts.forEach(copy_alert => { + const copy = this.copies.find(c => c.id() === copy_alert.copy()); + copy.copy_alerts( copy.copy_alerts().concat(copy_alert) ); + }); } - // Copy alerts for the selected copies which have not been - // acknowledged by staff and are within org unit range of - // the alert type. - getCopyAlerts(): Promise<any> { - const typeIds = this.alertTypes.map(a => a.id); - - return this.pcrud.search('aca', - {copy: this.copyIds, ack_time: null, alert_type: typeIds}, - {}, {atomic: true}) - .toPromise().then(alerts => { - alerts.forEach(a => { - const copy = this.copies.filter(c => c.id() === a.copy())[0]; - this.alertIdMap[a.id()] = a; - copy.copy_alerts().push(a); + protected async processCommonThings(): Promise<void> { + if (!this.inBatch()) { return; } + + let potentialMatches = this.copies[0].copy_alerts(); + + // Find alerts that match across all copies + this.copies.slice(1).forEach(copy => { + potentialMatches = potentialMatches.filter(alertFromFirstCopy => + copy.copy_alerts().some(alertFromCurrentCopy => + this.compositeMatch(alertFromFirstCopy, alertFromCurrentCopy) + ) + ); + }); + + this.alertsInCommon = potentialMatches.map(match => { + const proxy = this.cloneAlertForBatchProxy(match) as ProxyAlert; + // Collect IDs of all matching alerts across all copies + proxy.originalAlertIds = []; + this.copies.forEach(copy => { + copy.copy_alerts().forEach(alert => { + if (this.compositeMatch(alert, match)) { + proxy.originalAlertIds.push(alert.id()); + } }); - if (this.inBatch()) { - let potentialMatches = this.copies[0].copy_alerts(); - - this.copies.slice(1).forEach(copy => { - potentialMatches = potentialMatches.filter(alertFromFirstCopy => - copy.copy_alerts().some(alertFromCurrentCopy => - this.compositeMatch(alertFromFirstCopy, alertFromCurrentCopy) - ) - ); - }); - - // potentialMatches now contains alerts that have a "match" in every copy - this.alertsInCommon = potentialMatches.map( match => this.cloneAlertForBatchProxy(match) ); - } }); + return proxy; + }); } - getDefaultAlertType(): Promise<any> { - // TODO fetching the default item alert type from holdings editor - // defaults had previously been handled via methods from - // VolCopyService. However, as described in LP#2044051, this - // caused significant issues with dependency injection. - // Consequently, some refactoring may be in order so that - // such default values can be managed via a more self-contained - // service. - return this.serverStore.getItem('eg.cat.volcopy.defaults').then( - (defaults) => { - console.log('eg.cat.volcopy.defaults',defaults); - if (defaults?.values?.item_alert_type) { - console.log('eg.cat.volcopy.defaults, got here for item_alert_type',defaults.values.item_alert_type); - this.defaultAlertType = defaults.values.item_alert_type; - } - } - ); + protected compositeMatch(a: ICopyAlert, b: ICopyAlert): boolean { + return a.alert_type() === b.alert_type() && + a.temp() === b.temp() && + a.note() === b.note() && + Boolean(a.ack_time()) === Boolean(b.ack_time()); } - getAlertTypeLabel(alert: IdlObject): string { - const alertType = this.alertTypes.filter(t => t.id === alert.alert_type()); - return alertType[0].label; + private cloneAlertForBatchProxy(source: ICopyAlert): ICopyAlert { + const target = this.createNewThing(); + target.id(source.id()); + target.alert_type(source.alert_type()); + target.temp(source.temp()); + target.ack_time(source.ack_time()); + target.ack_staff(source.ack_staff()); + target.note(source.note()); + target.isnew(source.isnew()); + return target; } - removeAlert(alert: IdlObject) { - // the only type of alerts we can remove are pending ones that - // we have created during the lifetime of this modal; alerts - // that already exist can only be cleared - this.newAlerts = this.newAlerts.filter(t => t.id() !== alert.id()); + getAlertTypeLabel(alert: ICopyAlert): string { + const alertType = this.alertTypes?.find(t => t.id === alert.alert_type()); + return alertType ? alertType.label : ''; } - // Add the in-progress new note to all copies. - addNew() { + addNew(): void { + if (!this.validate()) { return; } this.newAlert.id(this.autoId--); this.newAlert.isnew(true); - this.newAlerts.push(this.newAlert); - - this.newAlert = this.prepNewAlert(); + this.newThings.push(this.newAlert); + this.newAlert = this.createNewThing(); + } + undeleteNote(alert: ICopyAlert): void { + alert.isdeleted( alert.isdeleted() ?? false ); + // console.debug('undeleteAlert, alert, alert.isdeleted()', alert, alert.isdeleted()); + super.removeThing([alert]); // it's a toggle } - compositeMatch(a: IdlObject, b: IdlObject): boolean { - return a.alert_type() === b.alert_type() - && a.temp() === b.temp() - && a.note() === b.note() - && ( - (a.ack_time() === null && b.ack_time() === null) - || (a.ack_time() !== null && b.ack_time() !== null) - ); + removeAlert(alert: ICopyAlert): void { + alert.isdeleted( alert.isdeleted() ?? false ); + // console.debug('removeAlert, alert, alert.isdeleted()', alert, alert.isdeleted()); + super.removeThing([alert]); } - setAlert(target: IdlObject, source: IdlObject) { - target.ack_staff(source.ack_staff()); - if (source.ack_time() === 'now') { - target.ack_time('now'); - target.ack_staff(this.auth.user().id()); + protected validate(): boolean { + const form = document.getElementById('new-alert-form') as HTMLFormElement; + const typeInput = document.getElementById('item-alert-type') as HTMLFormElement; + const typeError = document.getElementById('item-alert-type-error') as HTMLElement; + + form.classList.add('form-validated'); + + if (!this.newAlert.alert_type()) { + typeError.removeAttribute('hidden'); + setTimeout(() => typeInput.focus()); + // this.toast.danger($localize`Alert type is required`); + return false; } - target.ischanged(true); - target.alert_type(source.alert_type()); - target.temp(source.temp()); - target.ack_time(source.ack_time()); - target.note(source.note()); + typeError.setAttribute('hidden', ''); + return true; } - // clones everything but copy, create_time, and create_staff - // This is serving as a reference alert for the other matching alerts - cloneAlertForBatchProxy(source: IdlObject): IdlObject { - const target = this.idl.create('aca'); - target.id( source.id() ); - target.alert_type(source.alert_type()); - target.temp(source.temp()); - target.ack_time(source.ack_time()); - target.ack_staff(source.ack_staff()); - target.note(source.note()); - return target; + setAck(alert, $event) { + // (ngModelChange)="alert.ack_time($event ? 'now' : null); alert.ischanged(true)" + if ($event) { + // console.debug('setAck clear',alert,$event); + alert.ack_time('now'); + alert.ack_staff(this.auth.user().id()); + } else { + // console.debug('setAck reset',alert,$event); + alert.ack_time(null); + alert.ack_staff(null); + } + alert.ischanged(true); } - applyChanges() { + protected async applyChanges(): Promise<void> { + try { + // console.debug('CopyAlertsDialog, applyChanges, changedThings prior to rebuild', this.changedThings); + // console.debug('CopyAlertsDialog, applyChanges, deletedThings prior to rebuild', this.deletedThings); + // console.debug('CopyAlertsDialog, applyChanges, copies', this.copies); + this.changedThings = []; + this.deletedThings = []; - const changedAlerts = []; - const changes = this.hasCopy() - ? ( - this.inBatch() - ? this.alertsInCommon.filter(a => a.ischanged()) - : this.copies[0].copy_alerts().filter(a => a.ischanged()) - ) - : []; - console.log('applyChanges, changes', changes); - - changes.forEach(change => { + // Find alerts that have been modified if (this.inBatch()) { - this.copies.forEach(copy => { - copy.copy_alerts().forEach(realAlert => { - // compare against the unchanged version of the reference alert - if (realAlert.id() !== change.id() && this.compositeMatch(realAlert, this.alertIdMap[ change.id() ])) { - this.setAlert(realAlert, change); - changedAlerts.push(realAlert); - } - }); - }); - // now change the original reference alert as well - this.setAlert(this.alertIdMap[ change.id() ], change); - changedAlerts.push( this.alertIdMap[ change.id() ] ); + // For batch mode, look at alertsInCommon for changes + this.changedThings = this.alertsInCommon.filter(alert => alert.ischanged()); + this.deletedThings = this.alertsInCommon.filter(alert => alert.isdeleted()); + // console.debug('CopyAlertsDialog, applyChanges, changedThings rebuilt in batch context', this.changedThings); + // console.debug('CopyAlertsDialog, applyChanges, deletedThings rebuilt in batch context', this.deletedThings); + } else if (this.copies.length) { + // For single mode, look at the copy's alerts + this.changedThings = this.copies[0].copy_alerts() + .filter(alert => alert.ischanged()); + this.deletedThings = this.copies[0].copy_alerts() + .filter(alert => alert.isdeleted()); + // console.debug('CopyAlertsDialog, applyChanges, changedThings rebuilt in non-batch context', this.changedThings); + // console.debug('CopyAlertsDialog, applyChanges, deletedThings rebuilt in non-batch context', this.deletedThings); } else { - changedAlerts.push(change); + // console.debug('CopyAlertsDialog, applyChanges, inBatch() == false and this.copies.length == false'); } - }); - if (this.inPlaceCreateMode) { - this.close({ newAlerts: this.newAlerts, changedAlerts: changedAlerts }); - return; - } - console.log('changedAlerts.length, newAlerts.length', changedAlerts.length,this.newAlerts.length); - const pendingAlerts = changedAlerts; - - this.newAlerts.forEach(alert => { - this.copies.forEach(c => { - const a = this.idl.clone(alert); - a.isnew(true); - a.id(null); - a.copy(c.id()); - pendingAlerts.push(a); - }); - }); + if (this.inPlaceCreateMode) { + this.close(this.gatherChanges()); + return; + } - this.pcrud.autoApply(pendingAlerts).toPromise().then( - ok => { - this.successMsg.current().then(msg => this.toast.success(msg)); - this.close({ newAlerts: this.newAlerts, changedAlerts: changedAlerts }); - }, - err => { - this.errorMsg.current().then(msg => this.toast.danger(msg)); - console.error('pcrud error', err); + console.log('here', this); + + this.context.newAlerts = this.newThings; + this.context.changedAlerts = this.changedThings; + this.context.deletedAlerts = this.deletedThings; + + this.copies.forEach( c => this.context.updateInMemoryCopyWithAlerts(c) ); + + console.log('copies', this.copies); + + // Handle persistence ourselves + const result = await this.saveChanges(); + // console.debug('CopyAlertsDialogComponent, saveChanges() result', result); + if (result) { + this.showSuccess(); + this.alerts = []; this.copies = []; this.copyIds = []; + this.close(this.gatherChanges()); + } else { + this.showError('saveChanges failed'); } - ); + } catch (err) { + this.showError(err); + } + } +} + +export function inactiveEntry(): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } | null => + this.combobox.disableEntries.includes(control.value) + ? {inactiveEntry: control.value} : null; +} + +@Directive({ +// eslint-disable-next-line @angular-eslint/directive-selector + selector: '[validateDisabledSelection]', + providers: [{ provide: NG_VALIDATORS, useExisting: AlertTypeValidatorDirective, multi: true }] +}) + +export class AlertTypeValidatorDirective implements Validator { + + constructor(private combobox: ComboboxComponent) {} + + validate(control: AbstractControl): { [key: string]: any } | null { + return inactiveEntry()(control); } } diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-page.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-page.component.html new file mode 100644 index 0000000000..e09d73cd7b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-page.component.html @@ -0,0 +1,10 @@ +<eg-staff-banner> + <h1 i18n>Item Alerts</h1> +</eg-staff-banner> + +<eg-copy-alerts-dialog #copyAlertsDialog + [inPlaceCreateMode]="false"> +</eg-copy-alerts-dialog> + +<div class="container mt-4"> +</div> diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-page.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-page.component.ts new file mode 100644 index 0000000000..b85f7bf56f --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-page.component.ts @@ -0,0 +1,46 @@ +import { Component, Input, AfterViewInit, ViewChild } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { CopyAlertsDialogComponent } from './copy-alerts-dialog.component'; + +@Component({ + selector: 'eg-copy-alerts-page', + templateUrl: 'copy-alerts-page.component.html' +}) +export class CopyAlertsPageComponent implements AfterViewInit { + @ViewChild('copyAlertsDialog', {static: false}) + private copyAlertsDialog: CopyAlertsDialogComponent; + + copyIds = []; + + constructor( + private route: ActivatedRoute + ) {} + + ngAfterViewInit() { + console.debug('CopyAlertsPageComponent, ngAfterViewInit, this', this); + this.route.queryParams.subscribe(params => { + console.debug('CopyAlertsPageComponent, query params', params); + if (params['copyIds']) { + this.copyIds = params['copyIds'].split(',').map(id => parseInt(id, 10)); + this.openItemAlerts(); + } + }); + } + + openItemAlerts($event?) { + this.copyAlertsDialog.copyIds = this.copyIds; + // this.copyAlertsDialog.mode = 'manage'; + this.copyAlertsDialog.open({size: 'lg'}).subscribe({ + 'next': changes => { + console.debug('CopyAlertsPageComponent, copyAlertsDialog, open, next', changes); + }, + 'error': err => { + console.error('CopyAlertsPageComponent, copyAlertsDialog, open, error', err); + }, + 'complete': () => { + console.debug('CopyAlertsPageComponent, copyAlertsDialog, open, complete'); + window.close(); + } + }); + } +} diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-dialog.component.css b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-dialog.component.css new file mode 100644 index 0000000000..670967af5f --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-dialog.component.css @@ -0,0 +1,17 @@ +/* +.added { + border-inline-start: 3px solid var(--success-border); + background-color: var(--bs-success-bg-subtle); +} + +.deleted { + border-inline-start: 3px solid var(--danger-border); + background-color: var(--bs-danger-bg-subtle); +} +/**/ + +/* don't show a valid border; user might have touched this field in passing */ +.form-validated form textarea.ng-valid.required:not(.ng-untouched) { + border-left-color: var(--form-control-border) !important; + border-left-width: 1px !important; +} \ No newline at end of file diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-dialog.component.html index 608430e8d2..9349bb0ab4 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-dialog.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-dialog.component.html @@ -1,88 +1,182 @@ -<eg-string #successMsg text="Successfully Modified Item Notes" i18n-text></eg-string> -<eg-string #errorMsg text="Failed To Modify Item Notes" i18n-text></eg-string> +<eg-string #successMsg [text]="successMessage" i18n-text></eg-string> +<eg-string #errorMsg [text]="errorMessage" i18n-text></eg-string> <ng-template #dialogContent> - <div class="modal-header"> - <h4 class="modal-title"> - <ng-container *ngIf="mode === 'create'"> - <span i18n>Adding notes for {{copyIds.length}} item(s).</span> - </ng-container> - <ng-container *ngIf="mode === 'manage'"> - <span i18n>Managing notes for item {{copy.barcode()}}</span> - </ng-container> - <span i18n></span> - </h4> - <button type="button" class="btn-close btn-close-white" - i18n-aria-label aria-label="Close dialog" (click)="close()"></button> - </div> - <div class="modal-body"> - <ng-container #editDialogContent *ngIf="mode === 'edit' && idToEdit; else manageDialogContent"> - <eg-copy-notes-edit [recordId]="idToEdit" (doneWithEdits)="returnToManage()"> - </eg-copy-notes-edit> - </ng-container> - <ng-template #manageDialogContent> - <div class="p-4 form-validated"> - <ng-container *ngIf="mode === 'manage' && copy.notes().length"> - <h4 i18n>Existing Notes</h4> - <div class="row mt-2 p-2" *ngFor="let note of copy.notes()"> - <div class="col-lg-3">{{note.title()}}</div> - <div class="col-lg-5">{{note.value()}}</div> - <div class="col-lg-2"> - <button type="button" class="btn btn-outline-info" (click)="editNote(note)" i18n> - Edit - </button> - </div> - <div class="col-lg-2"> - <button type="button" class="btn btn-outline-danger" - (click)="removeNote(note)" i18n>Remove</button> - </div> - </div> - <hr/> - </ng-container> - - <h4 i18n>New Notes</h4> - <div class="row mt-2 p-2" *ngFor="let note of newNotes"> - <div class="col-lg-3">{{note.title()}}</div> - <div class="col-lg-7">{{note.value()}}</div> - <div class="col-lg-2"> - <button type="button" class="btn btn-outline-danger" (click)="removeNote(note)" i18n> - Remove - </button> - </div> +<eg-copy-things-dialog + [thingType]="thingType" + [copies]="copies" + [copyIds]="copyIds" + [batchWarningMessage]="batchWarningMessage" + [inBatch]="inBatch.bind(this)" + [onClose]="close.bind(this)" + [onApplyChanges]="applyChanges.bind(this)"> + + <h4 class="mt-4 border-bottom" i18n>New Item Note</h4> + + <!-- New note form --> + <form class="row mt-3 px-0" id="new-note-form" ngNativeValidate> + <!-- Note Title --> + <div class="col-lg-4"> + <label for="new-note-title" class="form-label" i18n>Title</label> + <textarea class="form-control" required rows="1" name="new-note-title" + id="new-note-title" aria-describedby="new-note-title-feedback" + (ngModelChange)="newNote.title($event)" + [ngModel]="newNote.title()" #newNoteTitle="ngModel" ngbAutofocus></textarea> + <div hidden id="new-note-title-feedback" class="invalid-feedback"> + <span class="badge badge-danger" i18n>Title is required</span> </div> + </div> - <div class="row mt-2 p-2 rounded border border-success"> - <div class="col-lg-12"> - <div class="row"> - <div class="col-lg-6"> - <input type="text" class="form-control" [(ngModel)]="curNoteTitle" - i18n-placeholder placeholder="Note title..."/> - </div> - <div class="col-lg-6"> - <div class="form-check"> - <input class="form-check-input" type="checkbox" - [(ngModel)]="curNotePublic" id="pub-check"> - <label class="form-label form-check-label" for="pub-check">Public Note</label> - </div> - </div> - </div> - <div class="row mt-3"> - <div class="col-lg-9"> - <textarea class="form-control" [(ngModel)]="curNote" - i18n-placeholder placeholder="Enter note value..."></textarea> - </div> - <div class="col-lg-3"> - <button type="button" class="btn btn-success" (click)="addNew()" i18n>Add Note</button> - </div> - </div> - </div> + <!-- Note Content --> + <div class="col-lg-5"> + <label for="new-note-value" class="form-label" i18n>Content</label> + <textarea class="form-control" required rows="2" name="new-note-value" + id="new-note-value" aria-describedby="new-note-value-feedback" + (ngModelChange)="newNote.value($event)" + [ngModel]="newNote.value()" #newNoteValue="ngModel"></textarea> + <div hidden id="new-note-value-feedback" class="invalid-feedback"> + <span class="badge badge-danger" i18n>Content is required</span> </div> + </div> + + <!-- Note Options --> + <div class="col-lg-3 mt-4"> + <div class="d-flex flex-wrap align-items-baseline justify-content-end"> + <!-- Public flag --> + <div class="form-check mb-2 me-auto"> + <input class="form-check-input" type="checkbox" + [ngModel]="newNote.pub() === 't' || newNote.pub() === true" + (ngModelChange)="newNote.pub($event ? 't' : 'f')" + id="new-note-pub" name="new-note-pub"> + <label class="form-label form-check-label" + for="new-note-pub" i18n> + Public? + </label> + </div> + + <!-- Add button --> + <button type="button" class="btn btn-success" + (click)="addNew()" i18n> + Add New + </button> </div> - </ng-template> - </div> + </div> + </form> + + + <!-- Pending notes list --> +<ng-container *ngIf="newThings && newThings.length"> + <h4 class="mt-4 border-bottom" i18n>Pending Item Notes</h4> + <ul class="list-group list-group-flush"> + <li class="list-group-item px-0 py-3 added" *ngFor="let note of newThings"> + <div class="row"> + <!-- Note Title --> + <div class="col-lg-4"> + <textarea class="form-control" rows="1" + i18n-placeholder placeholder="Note Title..." + (ngModelChange)="note.title($event);" + [ngModel]="note.title()"> + </textarea> + </div> + + <!-- Note Content --> + <div class="col-lg-5"> + <textarea class="form-control" rows="2" + i18n-placeholder placeholder="Note Content..." + (ngModelChange)="note.value($event);" + [ngModel]="note.value()"> + </textarea> + </div> + + <!-- Note Options --> + <div class="col-lg-3"> + <div class="d-flex d-flex flex-wrap align-items-baseline justify-content-end"> + <!-- Public flag --> + <div class="form-check mb-2 me-auto"> + <input class="form-check-input" type="checkbox" + [ngModel]="note.pub() === 't' || note.pub() === true" + (ngModelChange)="note.pub($event ? 't' : 'f');" + id="note-pub-{{note.id()}}"> + <label class="form-label form-check-label" + for="note-pub-{{note.id()}}" i18n> + Public? + </label> + </div> - <div class="modal-footer" *ngIf="mode !== 'edit'"> - <button type="button" class="btn btn-secondary" (click)="close()" i18n>Cancel</button> - <button type="button" class="btn btn-success me-2" (click)="applyChanges()" i18n>Apply Changes</button> + <!-- Remove button --> + <button type="button" class="btn-link btn-destroy" + (click)="removeNote(note)" i18n> + Remove + </button> + </div> + </div> + </div> + </li> +</ul> +</ng-container> + + <ng-container *ngIf="!templateOnlyMode"> + <h4 class="mt-4 border-bottom" i18n>Existing Item Notes</h4> + + <!-- Existing notes list --> + <ul class="list-group list-group-flush"> + + <li class="list-group-item px-0 py-3" [ngClass]="note.isdeleted() ? 'deleted' : ''" + *ngFor="let note of inBatch() ? notesInCommon : (copies.length ? copies[0].notes() : [])"> + <div *ngIf="!note.isnew()" class="row"> <!-- filter out pending notes on subsequent dialog opens --> + <!-- Note Title --> + <div class="col-lg-4"> + <textarea class="form-control" rows="1" + i18n-placeholder placeholder="Note Title..." + (ngModelChange)="note.title($event); note.ischanged(true)" + [ngModel]="note.title()"> + </textarea> + + <!-- Creation date - only show in single mode --> + <div *ngIf="!inBatch() && !templateOnlyMode" class="ps-2 pt-2 text-muted" i18n> + Added: {{note.create_date() | date:'shortDate'}} + </div> + </div> + + <!-- Note Content --> + <div class="col-lg-5"> + <textarea class="form-control" rows="2" + i18n-placeholder placeholder="Note Content..." + (ngModelChange)="note.value($event); note.ischanged(true)" + [ngModel]="note.value()"> + </textarea> + </div> + + <!-- Note Options --> + <div class="col-lg-3"> + <div class="d-flex flex-wrap align-items-baseline justify-content-end"> + <!-- Public flag --> + <div class="form-check mb-2 me-auto"> + <input class="form-check-input" type="checkbox" + [ngModel]="note.pub() === 't' || note.pub() === true" + (ngModelChange)="note.pub($event ? 't' : 'f'); note.ischanged(true)" + id="note-pub-{{note.id()}}"> + <label class="form-label form-check-label" + for="note-pub-{{note.id()}}" i18n> + Public? + </label> + </div> + + <!-- Remove button --> + <button *ngIf="!note.isdeleted()" type="button" class="btn-link btn-destroy" + (click)="removeNote(note)" i18n> + Remove + </button> + <button *ngIf="note.isdeleted()" type="button" class="btn-link btn-destroy" + (click)="undeleteNote(note)" i18n> + Undelete + </button> + </div> + </div> </div> + </li> + </ul> + </ng-container> + +</eg-copy-things-dialog> </ng-template> diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-dialog.component.ts index f393f82a9b..a68fb51525 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-dialog.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-dialog.component.ts @@ -1,190 +1,283 @@ -import {Component, Input, ViewChild} from '@angular/core'; -import {Observable, throwError, from, empty} from 'rxjs'; -import {switchMap} from 'rxjs/operators'; -import {NetService} from '@eg/core/net.service'; -import {IdlService, IdlObject} from '@eg/core/idl.service'; -import {ToastService} from '@eg/share/toast/toast.service'; -import {AuthService} from '@eg/core/auth.service'; -import {PcrudService} from '@eg/core/pcrud.service'; -import {OrgService} from '@eg/core/org.service'; -import {StringComponent} from '@eg/share/string/string.component'; -import {DialogComponent} from '@eg/share/dialog/dialog.component'; -import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap'; - -/** - * Dialog for managing copy notes. - */ - -export interface CopyNotesChanges { - newNotes: IdlObject[]; - delNotes: IdlObject[]; +/* eslint-disable max-len */ +import { Component, Input } from '@angular/core'; +import { IdlService, IdlObject } from '@eg/core/idl.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ToastService } from '@eg/share/toast/toast.service'; +import { AuthService } from '@eg/core/auth.service'; +import { PcrudService } from '@eg/core/pcrud.service'; +import { OrgService } from '@eg/core/org.service'; +import {VolCopyContext} from '@eg/staff/cat/volcopy/volcopy'; +import { + CopyThingsDialogComponent, + IThingObject, + IThingChanges, + IThingConfig +} from './copy-things-dialog.component'; +import {FormsModule, AbstractControl, NG_VALIDATORS, ValidationErrors, Validator, Validators, ValidatorFn} from '@angular/forms'; + +export interface ICopyNote extends IThingObject { + title(val?: string): string; + value(val?: string): string; + pub(val?: boolean): boolean; + creator(val?: number): number; + create_date(val?: any): any; + owning_copy(val?: number): number; +} + +interface ProxyNote extends ICopyNote { + originalNoteIds: number[]; +} +export interface ICopyNoteChanges extends IThingChanges<ICopyNote> { + newThings: ICopyNote[]; + changedThings: ICopyNote[]; + deletedThings: ICopyNote[]; } @Component({ selector: 'eg-copy-notes-dialog', - templateUrl: 'copy-notes-dialog.component.html' + templateUrl: 'copy-notes-dialog.component.html', + styleUrls: ['./copy-notes-dialog.component.css'] }) +export class CopyNotesDialogComponent extends + CopyThingsDialogComponent<ICopyNote, ICopyNoteChanges> { -export class CopyNotesDialogComponent - extends DialogComponent { - - // If there are multiple copyIds, only new notes may be applied. - // If there is only one copyId, then notes may be applied or removed. - @Input() copyIds: number[] = []; - - mode: string; // create | manage | edit + protected thingType = 'notes'; + protected successMessage = $localize`Successfully Modified Item Notes`; + protected errorMessage = $localize`Failed To Modify Item Notes`; + protected batchWarningMessage = + $localize`Note that items in batch do not share notes directly. Displayed notes represent matching note groups.`; - // If true, no attempt is made to save the new notes to the - // database. It's assumed this takes place in the calling code. - @Input() inPlaceCreateMode = false; + context: VolCopyContext; - // In 'create' mode, we may be adding notes to multiple copies. - copies: IdlObject[] = []; + // Note-specific properties + notesInCommon: ICopyNote[] = []; + newNote: ICopyNote; - // In 'manage' mode we only handle a single copy. - copy: IdlObject; - - curNote: string; - curNoteTitle: string; - curNotePublic = false; - newNotes: IdlObject[] = []; - delNotes: IdlObject[] = []; - - autoId = -1; - - idToEdit: number; - - @ViewChild('successMsg', { static: true }) private successMsg: StringComponent; - @ViewChild('errorMsg', { static: true }) private errorMsg: StringComponent; + notes: IdlObject[] = []; constructor( - private modal: NgbModal, // required for passing to parent - private toast: ToastService, - private net: NetService, - private idl: IdlService, - private pcrud: PcrudService, - private org: OrgService, - private auth: AuthService) { - super(modal); // required for subclassing + modal: NgbModal, + toast: ToastService, + idl: IdlService, + pcrud: PcrudService, + org: OrgService, + auth: AuthService + ) { + const config: IThingConfig<ICopyNote> = { + idlClass: 'acpn', + thingField: 'notes', + defaultValues: { + creator: auth.user().id(), + pub: false + } + }; + super(modal, toast, idl, pcrud, org, auth, config); + this.newNote = this.createNewThing(); + this.context = new VolCopyContext(); + this.context.org = org; // inject + this.context.idl = idl; // inject } - /** - */ - open(args: NgbModalOptions): Observable<CopyNotesChanges> { - this.copy = null; - this.copies = []; - this.newNotes = []; - this.delNotes = []; - - if (this.copyIds.length === 0 && !this.inPlaceCreateMode) { - return throwError('copy ID required'); + public async initialize(): Promise<void> { + if (!this.newNote) { + this.newNote = this.createNewThing(); } - - // In manage mode, we can only manage a single copy. - // But in create mode, we can add notes to multiple copies. - // We can only manage copies that already exist in the database. - if (this.copyIds.length === 1 && this.copyIds[0] > 0) { - this.mode = 'manage'; - } else { - this.mode = 'create'; - } - - // Observify data loading - const obs = from(this.getCopies()); - - // Return open() observable to caller - return obs.pipe(switchMap(_ => super.open(args))); + await super.initialize(); } - getCopies(): Promise<any> { - - // Avoid fetch if we're only adding notes to isnew copies. - const ids = this.copyIds.filter(id => id > 0); - if (ids.length === 0) { return Promise.resolve(); } - - return this.pcrud.search('acp', {id: this.copyIds}, - {flesh: 1, flesh_fields: {acp: ['notes']}}, - {atomic: true} - ) - .toPromise().then(copies => { - this.copies = copies; - if (copies.length === 1) { - this.copy = copies[0]; - } + protected async getThings(): Promise<void> { + if (this.copyIds.length === 0) { return; } + if (this.notes.length > 0) { + // console.debug('already have notes, trimming newThings from existing. newThings=', this.newThings); + this.copies.forEach( c => { + const newThingIds = this.newThings.map( aa => aa.id() ); + c.notes( + (c.notes() || []).filter( a => !newThingIds.includes(a.id()) ) + ); }); - } - - editNote(note: IdlObject) { - this.idToEdit = note.id(); - this.mode = 'edit'; - } - - returnToManage() { - this.getCopies().then(() => { - this.idToEdit = null; - this.mode = 'manage'; + return; + } // need to make sure this is cleared after a save. It is; the page reloads + + this.notes = await this.pcrud.search('acpn', + { owning_copy: this.copyIds }, + {}, + { atomic: true } + ).toPromise(); + + this.copies.forEach(c => c.notes([])); + this.notes.forEach(note => { + const copy = this.copies.find(c => c.id() === note.owning_copy()); + copy.notes( copy.notes().concat(note) ); }); } - removeNote(note: IdlObject) { - this.newNotes = this.newNotes.filter(t => t.id() !== note.id()); + protected async processCommonThings(): Promise<void> { + if (!this.inBatch()) { return; } - if (note.isnew() || this.mode === 'create') { return; } + let potentialMatches = this.copies[0].notes(); - const existing = this.copy.notes().filter(n => n.id() === note.id())[0]; - if (!existing) { return; } - - existing.isdeleted(true); - this.delNotes.push(existing); + // Find notes that match across all copies + this.copies.slice(1).forEach(copy => { + potentialMatches = potentialMatches.filter(noteFromFirstCopy => + copy.notes().some(noteFromCurrentCopy => + this.compositeMatch(noteFromFirstCopy, noteFromCurrentCopy) + ) + ); + }); - // Remove from copy for dialog display - this.copy.notes(this.copy.notes().filter(n => n.id() !== note.id())); + this.notesInCommon = potentialMatches.map(match => { + const proxy = this.cloneNoteForBatchProxy(match) as ProxyNote; + // Collect IDs of all matching notes across all copies + proxy.originalNoteIds = []; + this.copies.forEach(copy => { + copy.notes().forEach(note => { + if (this.compositeMatch(note, match)) { + proxy.originalNoteIds.push(note.id()); + } + }); + }); + return proxy; + }); } - addNew() { - if (!this.curNoteTitle || !this.curNote) { return; } + protected compositeMatch(a: ICopyNote, b: ICopyNote): boolean { + return a.title() === b.title() && + a.value() === b.value() && + a.pub() === b.pub(); + } - const note = this.idl.create('acpn'); - note.isnew(true); - note.creator(this.auth.user().id()); - note.pub(this.curNotePublic ? 't' : 'f'); - note.title(this.curNoteTitle); - note.value(this.curNote); - note.id(this.autoId--); + private cloneNoteForBatchProxy(source: ICopyNote): ICopyNote { + const target = this.createNewThing(); + target.id(source.id()); + target.title(source.title()); + target.value(source.value()); + target.pub(source.pub()); + target.isnew(source.isnew()); + return target; + } - this.newNotes.push(note); + addNew(): void { + if (!this.validate()) { return; } + + this.newNote.id(this.autoId--); + this.newNote.isnew(true); + this.newThings.push(this.newNote); + this.newNote = this.createNewThing(); + const form = document.getElementById('new-note-form') as HTMLFormElement; + // give createNewThing() a moment. + /* eslint-disable no-magic-numbers */ + setTimeout(() => { + form.reset(); + form.elements['new-note-title'].classList.remove('ng-invalid', 'ng-touched'); + form.elements['new-note-value'].classList.remove('ng-invalid', 'ng-touched'); + form.elements['new-note-title'].classList.add('ng-pristine', 'ng-untouched'); + form.elements['new-note-value'].classList.add('ng-pristine', 'ng-untouched'); + }, 5); + /* eslint-enable no-magic-numbers */ + } - this.curNote = ''; - this.curNoteTitle = ''; - this.curNotePublic = false; + undeleteNote(note: ICopyNote): void { + note.isdeleted( note.isdeleted() ?? false ); + // console.debug('undeleteNote, note, note.isdeleted()', note, note.isdeleted()); + super.removeThing([note]); // it's a toggle } - applyChanges() { + removeNote(note: ICopyNote): void { + note.isdeleted( note.isdeleted() ?? false ); + // console.debug('removeNote, note, note.isdeleted()', note, note.isdeleted()); + super.removeThing([note]); + } - if (this.inPlaceCreateMode) { - this.close({ newNotes: this.newNotes, delNotes: this.delNotes }); - return; + protected validate(): boolean { + let valid = true; + const form = document.getElementById('new-note-form') as HTMLFormElement; + const title = document.getElementById('new-note-title') as HTMLFormElement; + const value = document.getElementById('new-note-value') as HTMLFormElement; + const titleError = document.getElementById('new-note-title-feedback') as HTMLElement; + const valueError = document.getElementById('new-note-value-feedback') as HTMLElement; + + form.classList.add('form-validated'); + + if (!this.newNote.title()) { + title.classList.remove('ng-valid'); + title.classList.add('ng-invalid'); + titleError.removeAttribute('hidden'); + setTimeout(() => title.focus()); + // this.toast.danger($localize`Note title is required`); + valid = false; + } + if (!this.newNote.value()) { + value.classList.remove('ng-valid'); + value.classList.add('ng-invalid'); + valueError.removeAttribute('hidden'); + // if the title was valid but this is not... + if (valid) { + setTimeout(() => value.focus()); + } + // this.toast.danger($localize`Note content is required`); + valid = false; } + if (!valid) {return false;} - const notes = []; - this.newNotes.forEach(note => { - this.copies.forEach(copy => { - const n = this.idl.clone(note); - n.id(null); // remove temp ID, it will be duped - n.owning_copy(copy.id()); - notes.push(n); - }); - }); + titleError.setAttribute('hidden', ''); + valueError.setAttribute('hidden', ''); + return true; + } - this.pcrud.create(notes).toPromise() - .then(_ => { - if (this.delNotes.length) { - return this.pcrud.remove(this.delNotes).toPromise(); - } - }).then(_ => { - this.successMsg.current().then(msg => this.toast.success(msg)); - this.close({ newNotes: this.newNotes, delNotes: this.delNotes }); - }); + protected async applyChanges(): Promise<void> { + try { + // console.debug('CopyNotesDialog, applyChanges, changedThings prior to rebuild', this.changedThings.length, this.changedThings); + // console.debug('CopyNotesDialog, applyChanges, deletedThings prior to rebuild', this.deletedThings.length, this.deletedThings); + // console.debug('CopyNotesDialog, applyChanges, copies', this.copies); + this.changedThings = []; + this.deletedThings = []; + + // Find notes that have been modified + if (this.inBatch()) { + // For batch mode, look at notesInCommon for changes + this.changedThings = this.notesInCommon.filter(note => note.ischanged() ?? false); + this.deletedThings = this.notesInCommon.filter(note => note.isdeleted() ?? false); + // console.debug('CopyNotesDialog, applyChanges, changedThings rebuilt in batch context', this.changedThings.length, this.changedThings); + // console.debug('CopyNotesDialog, applyChanges, deletedThings rebuilt in batch context', this.deletedThings.length, this.deletedThings); + } else if (this.copies.length) { + // For single mode, look at the copy's alerts + this.changedThings = this.copies[0].notes() + .filter(note => note.ischanged()); + this.deletedThings = this.copies[0].notes() + .filter(note => note.isdeleted()); + // console.debug('CopyNotesDialog, applyChanges, changedThings rebuilt in non-batch context', this.changedThings.length, this.changedThings); + // console.debug('CopyNotesDialog, applyChanges, deletedThings rebuilt in non-batch context', this.deletedThings.length, this.deletedThings); + } else { + // console.debug('CopyNotesDialog, applyChanges, inBatch() == false and this.copies.length == false'); + } + + if (this.inPlaceCreateMode) { + this.close(this.gatherChanges()); + return; + } + + console.log('here', this); + + this.context.newNotes = this.newThings; + this.context.changedNotes = this.changedThings; + this.context.deletedNotes = this.deletedThings; + + this.copies.forEach( c => this.context.updateInMemoryCopyWithNotes(c) ); + + // console.debug('copies', this.copies); + + // Handle persistence ourselves + const result = await this.saveChanges(); + // console.debug('CopyNotesDialogComponent, saveChanges() result', result); + if (result) { + this.showSuccess(); + this.notes = []; this.copies = []; this.copyIds = []; + this.close(this.gatherChanges()); + } else { + this.showError('saveChanges failed'); + } + } catch (err) { + this.showError(err); + } } } - diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-tags-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-tags-dialog.component.html index 442bb9e90f..4e0759d361 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-tags-dialog.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-tags-dialog.component.html @@ -1,70 +1,52 @@ -<eg-string #successMsg text="Successfully Modified Item Tags" i18n-text></eg-string> -<eg-string #errorMsg text="Failed To Modify Item Tags" i18n-text></eg-string> +<eg-string #successMsg [text]="successMessage" i18n-text></eg-string> +<eg-string #errorMsg [text]="errorMessage" i18n-text></eg-string> <ng-template #dialogContent> - <div class="modal-header"> - <h4 class="modal-title"> - <ng-container *ngIf="mode === 'create'"> - <span i18n>Adding tags for {{copyIds.length}} item(s).</span> - </ng-container> - <ng-container *ngIf="mode === 'manage'"> - <span i18n>Managing tags for item {{copy.barcode()}}</span> - </ng-container> - <span i18n></span> - </h4> - <button type="button" class="btn-close btn-close-white" - i18n-aria-label aria-label="Close dialog" (click)="close()"></button> - </div> - <div class="modal-body p-4 form-validated"> - - <ng-container *ngIf="mode === 'manage' && copy.tags().length"> - <h4 i18n>Existing Tags</h4> - <div class="row mt-2 p-2" *ngFor="let map of copy.tags()"> - <div class="col-lg-4">{{map.tag().tag_type().label()}}</div> - <div class="col-lg-5">{{map.tag().label()}}</div> - <div class="col-lg-3"> - <button type="button" class="btn btn-outline-danger" (click)="removeTag(map.tag())" i18n> - Remove - </button> - </div> - </div> - <hr/> - </ng-container> +<eg-copy-things-dialog + [thingType]="thingType" + [copies]="copies" + [copyIds]="copyIds" + [batchWarningMessage]="batchWarningMessage" + [inBatch]="inBatch.bind(this)" + [onClose]="close.bind(this)" + [onApplyChanges]="applyChanges.bind(this)"> - <h4 i18n>New Tags</h4> - <div class="row mt-2 p-2" *ngFor="let tag of newTags"> - <ng-container *ngIf="!tag.isdeleted()"> - <div class="col-lg-4">{{tagTypeMap[tag.tag_type()].label()}}</div> - <div class="col-lg-5">{{tag.label()}}</div> - <div class="col-lg-3"> - <button type="button" class="btn btn-outline-danger" (click)="removeTag(tag)" i18n> - Remove - </button> - </div> - </ng-container> - </div> - - <div class="row mt-2 p-2 rounded border border-success"> - <div class="col-lg-4"> - <eg-combobox [entries]="tagTypes" [(ngModel)]="curTagType" - i18n-placeholder placeholder="Select tag type..."> - </eg-combobox> - </div> - <div class="col-lg-5"> - <eg-combobox [asyncDataSource]="tagDataSource" [(ngModel)]="curTag" - [allowFreeText]="true" - i18n-placeholder placeholder="Enter tag label..."> - </eg-combobox> - </div> - <div class="col-lg-3"> - <div class="pt-2"> - <button type="button" class="btn btn-success" (click)="addNew()" i18n>Add Tag</button> - </div> - </div> + <div class="row"> + <div class="col"> + <h3 i18n>Add Tag</h3> </div> </div> - <div class="modal-footer"> - <button type="button" class="btn btn-secondary" (click)="close()" i18n>Cancel</button> - <button type="button" class="btn btn-success me-2" (click)="applyChanges()" i18n>Apply Changes</button> + <div class="row my-2"> + <div class="col-lg-4"> + <eg-combobox [entries]="tagTypes" [(ngModel)]="curTagType" [ngbAutofocus]="true" + i18n-placeholder placeholder="Select tag type..."> + </eg-combobox> + </div> + <div class="col-lg-5"> + <eg-combobox [asyncDataSource]="tagDataSource" [(ngModel)]="curTag" + [allowFreeText]="true" [icons]="false" (comboboxEnter)="addThenRefresh()" + i18n-placeholder placeholder="Enter tag value..."> + </eg-combobox> + </div> + <div class="col-lg-3 new-tag-actions"> + <button [disabled]="curTag && !curTag.id" type="button" class="btn btn-normal" (click)="addThenRefresh()" i18n>Add Tag</button> + <button [disabled]="!curTag || curTag.id || !curTag.freetext" type="button" class="btn btn-success" (click)="addThenRefresh()" i18n>Create New Tag</button> + </div> </div> + + <!-- Combined Tag Maps --> + <eg-tag-map-list #combinedTagsMapsList + [maps]="allMapRows" + [newThings]="newThings" + [copyIds]="copyIds" + [code2cctt]="code2type" + [trickery]="trickeryExistingTagMapsList" + i18n-headerText headerText="Tags" + i18n-buttonText buttonText="Remove Tag" + [showIsDeleted]="true" + (remove)="removeThing($event)"> + </eg-tag-map-list> + + +</eg-copy-things-dialog> </ng-template> diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-tags-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-tags-dialog.component.ts index 651aca48fc..b17555c64f 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-tags-dialog.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-tags-dialog.component.ts @@ -1,78 +1,138 @@ -import {Component, OnInit, Input, ViewChild} from '@angular/core'; -import {Observable, throwError, from, EMPTY} from 'rxjs'; -import {tap, map, switchMap} from 'rxjs/operators'; -import {NetService} from '@eg/core/net.service'; -import {IdlService, IdlObject} from '@eg/core/idl.service'; -import {EventService} from '@eg/core/event.service'; -import {ToastService} from '@eg/share/toast/toast.service'; -import {AuthService} from '@eg/core/auth.service'; -import {PcrudService} from '@eg/core/pcrud.service'; -import {OrgService} from '@eg/core/org.service'; -import {StringComponent} from '@eg/share/string/string.component'; -import {DialogComponent} from '@eg/share/dialog/dialog.component'; -import {NgbModal, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap'; -import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; - -/** - * Dialog for managing copy tags. - */ - -export interface CopyTagChanges { - newTags: IdlObject[]; - deletedMaps: IdlObject[]; +/* eslint-disable max-len */ +import { Component, Input, ViewChild } from '@angular/core'; +import { lastValueFrom, Observable, EMPTY } from 'rxjs'; +import { map, defaultIfEmpty, tap } from 'rxjs/operators'; +import { IdlService, IdlObject } from '@eg/core/idl.service'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ToastService } from '@eg/share/toast/toast.service'; +import { AuthService } from '@eg/core/auth.service'; +import { PcrudService } from '@eg/core/pcrud.service'; +import { OrgService } from '@eg/core/org.service'; +import {VolCopyContext} from '@eg/staff/cat/volcopy/volcopy'; +import { + CopyThingsDialogComponent, + IThingObject, + IThingChanges, + IThingConfig +} from './copy-things-dialog.component'; +import { ComboboxEntry } from '@eg/share/combobox/combobox.component'; +import { TagMapListComponent } from './tag-map-list.component'; + +// Interface for tag maps with the composite match functionality +export interface ICopyTagMap extends IThingObject { + tag(val?: IdlObject): IdlObject; + copy(val?: number): number; +} + +// For batch operations, we track original tag map IDs +interface ProxyTagMap extends ICopyTagMap { + originalTagMapIds: number[]; +} + +// Changes structure following the base pattern +export interface ICopyTagMapChanges extends IThingChanges<ICopyTagMap> { + newThings: ICopyTagMap[]; + changedThings: ICopyTagMap[]; + deletedThings: ICopyTagMap[]; } @Component({ selector: 'eg-copy-tags-dialog', - templateUrl: 'copy-tags-dialog.component.html' + templateUrl: 'copy-tags-dialog.component.html', + styles: [ + 'kbd:first-letter { text-transform: none; }', + '.new-tag-actions button[disabled] { display: none; }', + '.dl-grid { grid-template-columns: auto 1fr; }' + ] }) +export class CopyTagsDialogComponent extends + CopyThingsDialogComponent<ICopyTagMap, ICopyTagMapChanges> { -export class CopyTagsDialogComponent - extends DialogComponent implements OnInit { + protected thingType = 'tag maps'; + protected successMessage = $localize`Successfully Modified Item Tag Maps`; + protected errorMessage = $localize`Failed To Modify Item Tag Maps`; + protected batchWarningMessage = ''; - // If there are multiple copyIds, only new tags may be applied. - // If there is only one copyId, then tags may be applied or removed. - @Input() copyIds: number[] = []; + context: VolCopyContext; - mode: string; // create | manage + // Tag-specific properties + allTags = []; + allTagsInCommon = []; + tagMaps = []; + tagTypes: ComboboxEntry[]; + tagMapsInCommon: ICopyTagMap[] = []; + newTagMap: ICopyTagMap; + curTag: ComboboxEntry = null; + curTagType: ComboboxEntry = null; + id2tag: {[id: number]: IdlObject} = {}; + code2type: {[id: string]: IdlObject} = {}; + autoId = -1; + tagDataSource: (term: string) => Observable<ComboboxEntry>; + tagDataSourceForEdits: { [key: number]: (term: string) => Observable<ComboboxEntry> } = {}; - // If true, no attempt is made to save the new tags to the - // database. It's assumed this takes place in the calling code. - @Input() inPlaceCreateMode = false; + // ViewChild does not work in this context; see trickeryPendingMapsList + combinedTagMapsList: TagMapListComponent; - // In 'create' mode, we may be adding notes to multiple copies. - copies: IdlObject[] = []; + allMapRows: ICopyTagMap[] = []; + allTagIds: number[] = []; - // In 'manage' mode we only handle a single copy. - copy: IdlObject; + liveAllTagIds() { return this.allTags.map(t => t.id()); } + liveAllTagsInCommon() { return this.allTagsInCommon.map(t => t.id()); } + liveTagMapIds() { return this.tagMaps.map(m => m.id()); } - tagTypes: ComboboxEntry[]; + constructor( + modal: NgbModal, + toast: ToastService, + idl: IdlService, + pcrud: PcrudService, + org: OrgService, + auth: AuthService + ) { + const config: IThingConfig<ICopyTagMap> = { + idlClass: 'acptcm', + thingField: 'tags', + fleshDepth: 3, + fleshFields: {'acp':['tags'], 'acptcm': ['tag'], 'acpt': ['tag_type'] }, + defaultValues: {} + }; + super(modal, toast, idl, pcrud, org, auth, config); - curTag: ComboboxEntry = null; - curTagType: ComboboxEntry = null; - newTags: IdlObject[] = []; - deletedMaps: IdlObject[] = []; - tagMap: {[id: number]: IdlObject} = {}; - tagTypeMap: {[id: number]: IdlObject} = {}; + this.setupTagDataSource(); + this.newTagMap = this.createNewThing(); + this.context = new VolCopyContext(); + this.context.org = org; // inject + this.context.idl = idl; // inject + } - tagDataSource: (term: string) => Observable<ComboboxEntry>; + public async initialize(): Promise<void> { + await this.getTagTypes(); + if (!this.newTagMap) { + this.newTagMap = this.createNewThing(); + } + await super.initialize(); - @ViewChild('successMsg', { static: true }) private successMsg: StringComponent; - @ViewChild('errorMsg', { static: true }) private errorMsg: StringComponent; + this.allMapRows = this.allMaps(); + this.allTagIds = [...new Set(this.getTagIdsFromMaps(this.allMaps()).concat(this.getTagIdsFromMaps(this.newThings)))]; + // console.debug('allTagIds: ', this.allTagIds); - constructor( - private modal: NgbModal, // required for passing to parent - private toast: ToastService, - private net: NetService, - private idl: IdlService, - private pcrud: PcrudService, - private org: OrgService, - private auth: AuthService) { - super(modal); // required for subclassing + this.newThings.forEach(tagMap => this.setupTagDataSourceForEdits(tagMap)); + (this.inBatch() ? this.tagMapsInCommon : (this.copies.length ? this.copies[0].tags() : [])).forEach( + tagMap => this.setupTagDataSourceForEdits(tagMap)); } - ngOnInit() { + getTagIdsFromMaps(tagMaps: ICopyTagMap[]): number[] { + if (!tagMaps || !tagMaps.length) {return [];} + return tagMaps.map(tagMap => tagMap.tag().id()); + } + + trickeryExistingTagMapsList = (that: any) => { + // console.debug('trickeryExistingTagMapsList, that', that); + this.combinedTagMapsList = that; + }; + + // Typeahead data for Add New tag value + private setupTagDataSource() { this.tagDataSource = term => { if (!this.curTagType) { return EMPTY; } @@ -86,155 +146,384 @@ export class CopyTagsDialogComponent owner: this.org.ancestors(this.auth.user().ws_ou(), true) }, {order_by: {acpt: 'label'}} - ).pipe(map(copyTag => { - this.tagMap[copyTag.id()] = copyTag; - return {id: copyTag.id(), label: copyTag.label()}; + ).pipe(map(tag => { + this.id2tag[tag.id()] = tag; + if (!this.allTagIds.includes(tag.id())) { + return {id: tag.id(), label: tag.label()}; + } })); }; } - /** - */ - open(args: NgbModalOptions): Observable<CopyTagChanges> { - this.copy = null; - this.copies = []; - this.newTags = []; - this.deletedMaps = []; - - if (this.copyIds.length === 0 && !this.inPlaceCreateMode) { - return throwError('copy ID required'); - } - - // In manage mode, we can only manage a single copy. - // But in create mode, we can add tags to multiple copies. - // We can only manage copies that already exist in the database. - if (this.copyIds.length === 1 && this.copyIds[0] > 0) { - this.mode = 'manage'; - } else { - this.mode = 'create'; - } + private setupTagDataSourceForEdits(tagMap) { + // console.debug('setupTagDataSourceForEdits',tagMap); + if (this.tagDataSourceForEdits[tagMap.id()]) { return; } - // Observify data loading - const obs = from(this.getTagTypes().then(_ => this.getCopies())); + this.tagDataSourceForEdits[tagMap.id()] = term => { + if (!tagMap.tag().tag_type()) { return EMPTY; } - // Return open() observable to caller - return obs.pipe(switchMap(_ => super.open(args))); + return this.pcrud.search( + 'acpt', { + tag_type: tagMap.tag().tag_type(), + '-or': [ + {value: {'ilike': `%${term}%`}}, + {label: {'ilike': `%${term}%`}} + ], + owner: this.org.ancestors(this.auth.user().ws_ou(), true) + }, + {order_by: {acpt: 'label'}} + ).pipe(map(tag => { + this.id2tag[tag.id()] = tag; + return {id: tag.id(), label: tag.label()}; + })); + }; } - getTagTypes(): Promise<any> { - if (this.tagTypes) { return Promise.resolve(); } + private async getTagTypes(): Promise<void> { + if (this.tagTypes) { return; } - this.tagTypes = []; - return this.pcrud.search('cctt', + const types = await this.pcrud.search('cctt', {owner: this.org.ancestors(this.auth.user().ws_ou(), true)}, - {order_by: {cctt: 'label'}} - ).pipe(tap(tag => { - this.tagTypeMap[tag.code()] = tag; - this.tagTypes.push({id: tag.code(), label: tag.label()}); - })).toPromise(); + {order_by: {cctt: 'label'}}, + {atomic: true} + ).toPromise(); + + this.tagTypes = types.map(type => ({ + id: type.code(), + label: type.label() + })); + + types.forEach(type => this.code2type[type.code()] = type); } - getCopies(): Promise<any> { - return this.pcrud.search('acp', {id: this.copyIds}, - {flesh: 3, flesh_fields: { - acp: ['tags'], acptcm: ['tag'], acpt: ['tag_type']}}, + protected async getThings(): Promise<void> { + if (this.copyIds.length === 0) { return; } + + if (this.tagMaps.length > 0) { + // console.debug('already have tagMaps, trimming newThings from existing. newThings=', this.newThings); + this.copies.forEach( c => { + const newThingIds = this.newThings.map( aa => aa.id() ); + c.tags( + (c.tags() || []).filter( a => !newThingIds.includes(a.id()) ) + ); + }); + return; + } // need to make sure this is cleared after a save. It is; the page reloads + /***/ + + // (note: then we also need to update counts in copy-attrs.component.html + // to count tags, not maps) + this.tagMaps = await this.pcrud.search('acptcm', + {copy: this.copyIds}, + {flesh: 2, flesh_fields: {acptcm: ['tag'], acpt: ['tag_type']}}, {atomic: true} - ) - .toPromise().then(copies => { - this.copies = copies; - if (copies.length === 1) { - this.copy = copies[0]; + ).toPromise(); + + this.copies.forEach(c => c.tags([])); + this.allTags = []; + const seenTagIds = new Map(); + this.tagMaps.forEach(tagMap => { + const copy = this.copies.find(c => c.id() === tagMap.copy()); + copy.tags( copy.tags().concat(tagMap) ); + + const tag = tagMap.tag(); + if (tag) { + const tagId = this.idl.pkeyValue(tag); + if (tagId && !seenTagIds.has(tagId)) { + seenTagIds.set(tagId, true); + this.allTags.push(tag); } - }); + } + }); + } + + protected compositeMatch(a: ICopyTagMap, b: ICopyTagMap): boolean { + const aTag = a.tag(); + const bTag = b.tag(); + return this.idl.pkeyValue( aTag ) === this.idl.pkeyValue( bTag ); + /* return aTag.tag_type() === bTag.tag_type() + && aTag.label() === bTag.label() + && aTag.value() === bTag.value() + && aTag.staff_note() === bTag.staff_note() + && aTag.pub() === bTag.pub() + && aTag.owner() === bTag.owner() + && aTag.url() === bTag.url();*/ } - removeTag(tag: IdlObject) { - this.newTags = this.newTags.filter(t => t.id() !== tag.id()); + protected async processCommonThings(): Promise<void> { + if (!this.inBatch()) { return; } - if (tag.isnew() || this.mode === 'create') { return; } + let potentialMatches = this.copies[0].tags(); - const existing = this.copy.tags().filter(m => m.tag().id() === tag.id())[0]; - if (!existing) { return; } + this.copies.slice(1).forEach(copy => { + potentialMatches = potentialMatches.filter(mapFromFirstCopy => + copy.tags().some(mapFromCurrentCopy => + this.compositeMatch(mapFromFirstCopy, mapFromCurrentCopy) + ) + ); + }); + if (potentialMatches.find( m => !m)) { + console.error('Falsy element in potentialMatches', this.idl.clone(potentialMatches)); + } - existing.isdeleted(true); - this.deletedMaps.push(existing); - this.copy.tags(this.copy.tags().filter(m => m.tag().id() !== tag.id())); - this.copy.ischanged(true); + const seenTagIds = new Map(); + this.allTagsInCommon = []; + this.tagMapsInCommon = potentialMatches + .filter(match => match) + .map(match => { + const proxy = this.cloneMapForBatchProxy(match) as ProxyTagMap; + proxy.originalTagMapIds = []; + this.copies.forEach(copy => { + copy.tags().forEach(tagMap => { + if (this.compositeMatch(tagMap, match)) { + proxy.originalTagMapIds.push(tagMap.id()); + const tag = tagMap.tag(); + if (tag) { + const tagId = this.idl.pkeyValue(tag); + if (tagId && !seenTagIds.has(tagId)) { + seenTagIds.set(tagId, true); + this.allTagsInCommon.push(tag); + } + } + } + }); + }); + if (!proxy) { + console.error('proxy undefined when match =', this.idl.clone(match)); + } + return proxy; + }) + .filter(proxy => proxy && proxy.originalTagMapIds && proxy.originalTagMapIds.length > 0); } - addNew() { - if (!this.curTagType || !this.curTag) { return; } + private cloneMapForBatchProxy(source: ICopyTagMap): ICopyTagMap { + const target = this.createNewThing(); + target.id(source.id()); + target.tag(source.tag()); + return target; + } + + protected createNewThing(): ICopyTagMap { + const newThing = super.createNewThing(); + // console.debug('createNewThing, newThing', newThing.id(), newThing); + return newThing; + } - let tag; + async addThenRefresh() { + await this.addNew().then( + resolve => { + // console.debug('addThenRefresh succeeded: ', resolve); + if (this.combinedTagMapsList) {this.combinedTagMapsList.reload(this.allMapRows, this.newThings);} + this.curTag = null; + }, + reject => { + // console.debug('addThenRefresh failed: ', reject); + } + ); + } + + async addNew(): Promise<void> { + if (!this.validate()) { return; } - if (this.curTag.freetext) { - // Create a new tag w/ the provided tag text. - tag = this.idl.create('acpt'); - tag.isnew(true); - tag.tag_type(this.curTagType.id); - tag.label(this.curTag.label); - tag.owner(this.auth.user().ws_ou()); - tag.pub('t'); + if (!this.curTagType || !this.curTag) { return; } + + let selectedTag; + if (!this.curTag.id && this.curTag.freetext) { + selectedTag = await this.insertNewTag(); } else { - tag = this.tagMap[this.curTag.id]; + selectedTag = this.id2tag[this.curTag.id]; + } + if (typeof selectedTag.tag_type() === 'string') { + selectedTag.tag_type( this.code2type[ selectedTag.tag_type() ] ); + } + // console.debug('addNew, selectedTag', selectedTag); + + this.copies.forEach(copy => { + if (copy.tags().includes(selectedTag.id())) { + // console.debug(`Copy ${copy.id()} already has tag ${selectedTag.id()}`); + } + + }); + this.newTagMap.id(this.autoId--); + this.newTagMap.isnew(true); + this.newTagMap.tag(selectedTag); // what was a stub now gets filled in and added to newThings + this.newThings.push( this.newTagMap ); + this.newTagMap = this.createNewThing(); // this preps an entry for the new tag map form + /* + if (!this.allTagIds.includes(selectedTag)) { + this.allMapRows.unshift(this.newTagMap); + this.allTagIds.unshift(this.newTagMap.tag().id()); } + /** */ - this.newTags.push(tag); + this.setupTagDataSourceForEdits(this.newTagMap); + // console.debug('New Things: ', this.newThings); + // addThenRefresh() will reload so we don't reload for every copy in a batch } - createNewTags(): Promise<any> { - let promise = Promise.resolve(); + protected async insertNewTag(): Promise<number> { + const id = null; + const newTag = this.idl.create('acpt'); + newTag.id(null); + newTag.isnew(true); + newTag.label(this.curTag.label); + newTag.value(this.curTag.label); + newTag.owner(this.auth.user().ws_ou()); + newTag.tag_type(this.curTagType.id); + newTag.pub('t'); + + const resp = await lastValueFrom( + this.pcrud.autoApply([newTag]) + .pipe( + tap({ + next: (val) => console.debug('CopyTagsDialog, insertNewTag, pcrud.autoApply next', val), + error: (err: unknown) => console.error('CopyTagsDialog, insertNewTag, pcrud.autoApply err', err), + complete: () => console.debug('CopyTagsDialog, insertNewTag, pcrud.autoApply completed') + }), + defaultIfEmpty(null) + ) + ); + // console.debug('insertNewTag, pcrud resp', resp); + resp.tag_type(this.code2type[ resp.tag_type() ]); + + return resp; + } - this.newTags.forEach(tag => { - if (!tag.isnew()) { return; } + protected validate(): boolean { + if (!this.curTagType) { + this.toast.danger($localize`Tag type is required`); + return false; + } + if (!this.curTag) { + this.toast.danger($localize`Tag selection is required`); + return false; + } + return true; + } - promise = promise.then(_ => { - return this.pcrud.create(tag).toPromise().then(id => { - console.log('create returned ', id); - tag.id(id); - }); - }); - }); + undeleteTagMap(tagMap: ICopyTagMap): void { + // send to delete and it just acts as a toggle in copy-things-dialog + // console.debug('undeleteTagMap, tagMap', tagMap); + this.removeThing([tagMap]); + } - return promise; + removeTagMap(tagMap: ICopyTagMap): void { + // console.debug('removeTagMap, tagMap', tagMap); + this.removeThing([tagMap]); } - deleteMaps(): Promise<any> { - if (this.deletedMaps.length === 0) { return Promise.resolve(); } - return this.pcrud.remove(this.deletedMaps).toPromise(); + removeThing(maps: ICopyTagMap[]): void { + // console.debug('tags, removeThing, maps', maps); + super.removeThing(maps); + // console.debug('back from super.removeThing'); + // refresh display + if (this.combinedTagMapsList) { + // console.debug('attempting to reload combinedTagMapsList'); + this.combinedTagMapsList.reload(this.allMapRows, this.newThings); + } else { + // console.debug('no combinedTagMapsList to reload'); + } } - applyChanges() { + protected async applyChanges(): Promise<void> { + try { + // console.debug('CopyTagsDialog, applyChanges, changedThings prior to rebuild', this.changedThings); + // console.debug('CopyTagsDialog, applyChanges, deletedThings prior to rebuild', this.deletedThings); + // console.debug('CopyTagsDialog, applyChanges, copies', this.copies); + this.changedThings = []; + this.deletedThings = []; + + // Find tagMaps that have been modified + if (this.inBatch()) { + // For batch mode, look at tagMapsInCommon for changes + this.changedThings = this.tagMapsInCommon.filter(m => m.ischanged()); + this.deletedThings = this.tagMapsInCommon.filter(m => m.isdeleted()); + // console.debug('CopyTagsDialog, applyChanges, changedThings rebuilt in batch context', this.changedThings); + // console.debug('CopyTagsDialog, applyChanges, deletedThings rebuilt in batch context', this.deletedThings); + } else if (this.copies.length) { + // For single mode, look at the copy's tags + this.changedThings = this.copies[0].tags() + .filter(m => m.ischanged()); + this.deletedThings = this.copies[0].tags() + .filter(m => m.isdeleted()); + // console.debug('CopyTagsDialog, applyChanges, changedThings rebuilt in non-batch context', this.changedThings); + // console.debug('CopyTagsDialog, applyChanges, deletedThings rebuilt in non-batch context', this.deletedThings); + } else { + // console.debug('CopyTagsDialog, applyChanges, inBatch() == false and this.copies.length == false'); + } + + if (this.inPlaceCreateMode) { + this.close(this.gatherChanges()); + return; + } + + // console.debug('here', this); + + this.context.newTagMaps = this.newThings; + this.context.changedTagMaps = this.changedThings; + this.context.deletedTagMaps = this.deletedThings; + + this.copies.forEach( c => this.context.updateInMemoryCopyWithTags(c) ); + + // console.debug('copies', this.copies); + + // Handle persistence ourselves + const result = await this.saveChanges(); + // console.debug('CopyTagsDialogComponent, saveChanges() result', result); + if (result) { + this.showSuccess(); + this.tagMaps = []; this.copies = []; this.copyIds = []; + this.close(this.gatherChanges()); + } else { + this.showError('saveChanges failed'); + } + } catch (err) { + this.showError(err); + } + } - if (this.inPlaceCreateMode) { - this.close({ newTags: this.newTags, deletedMaps: this.deletedMaps }); - return; + updateTagMapTagType(tagMap, $event) { + // console.debug('updateTagMapType, tagMap, $event', tagMap, $event); + tagMap.tag().tag_type( this.code2type[$event.id] ); + if (! (tagMap.isnew() ?? false)) { + tagMap.ischanged(true); } + } - let promise = this.deleteMaps().then(_ => this.createNewTags()); + updateTagMap(tagMap, $event) { + // console.debug('updateTagMap, tagMap, $event', tagMap, $event); + tagMap.tag( this.id2tag[$event.id] ); + if (typeof tagMap.tag().tag_type() === 'string') { + tagMap.tag().tag_type( this.code2type[ tagMap.tag().tag_type() ] ); + } + if (! (tagMap.isnew() ?? false)) { + tagMap.ischanged(true); + } + } - this.newTags.forEach(tag => { - this.copies.forEach(copy => { + tagMapTagType2ComboId(tagMap) { + if (!tagMap) { return null; } + if (!tagMap.tag()) { return null; } + if (!tagMap.tag().tag_type()) { return null; } + if (typeof tagMap.tag().tag_type() === 'string') { + return tagMap.tag().tag_type(); + } else { + return tagMap.tag().tag_type().code(); + } + } - if (copy.tags() && copy.tags().filter( - m => m.tag().id() === tag.id()).length > 0) { - return; // map already exists - } + allMaps() { + const allMaps = this.inBatch() ? this.tagMapsInCommon : this.copies[0]?.tags(); + if (typeof allMaps !== 'undefined' && allMaps.length) { + allMaps.forEach(m => { + if (!m.id()) {return;} - promise = promise.then(_ => { - const tagMap = this.idl.create('acptcm'); - tagMap.isnew(true); - tagMap.copy(copy.id()); - tagMap.tag(tag.id()); - return this.pcrud.create(tagMap).toPromise(); - }); + if (this.deletedThings.map(t => t.id()).includes(m.id())) { + m.isdeleted(true); + } }); - }); - - promise.then(_ => { - this.successMsg.current().then(msg => this.toast.success(msg)); - this.close({ newTags: this.newTags, deletedMaps: this.deletedMaps }); - }); + } + // console.debug('Existing maps: ', allMaps); + return allMaps; } } - diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-things-dialog-wrapper.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-things-dialog-wrapper.component.html new file mode 100644 index 0000000000..7817dadd48 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-things-dialog-wrapper.component.html @@ -0,0 +1,28 @@ +<div class="modal-header"> + <h2 class="modal-title"> + <span *ngIf="!inBatch()" i18n>Managing {{thingType === 'tag maps' ? 'tags' : thingType}} for item {{copies.length ? copies[0].barcode() : ''}}</span> + <span *ngIf="inBatch()" i18n>Managing {{thingType === 'tag maps' ? 'tags' : thingType}} in common for {{copyIds.length}} item(s)</span> + </h2> + <button type="button" class="btn-close btn-close-white" + i18n-aria-label aria-label="Close dialog" (click)="onClose()"> + </button> +</div> + +<div class="modal-body p-4"> + <!-- Batch warning message if needed --> + <p *ngIf="inBatch() && batchWarningMessage" class="text-muted"> + {{batchWarningMessage}} + </p> + + <!-- Projected content --> + <ng-content></ng-content> +</div> + +<div class="modal-footer"> + <a *ngIf="thingType === 'tag maps'" class="btn btn-secondary me-auto" target="_blank" routerLink="/staff/admin/local/asset/copy_tag" i18n>Manage Tags</a> + + <button type="button" class="btn btn-secondary" + (click)="onClose()" i18n>Close</button> + <button type="button" class="btn btn-success me-2" + (click)="onApplyChanges()" i18n>Apply Changes</button> +</div> diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-things-dialog-wrapper.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-things-dialog-wrapper.component.ts new file mode 100644 index 0000000000..6c1d0fdf35 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-things-dialog-wrapper.component.ts @@ -0,0 +1,18 @@ +import { Component, Input } from '@angular/core'; +import { IdlObject } from '@eg/core/idl.service'; + +@Component({ + selector: 'eg-copy-things-dialog', + templateUrl: './copy-things-dialog-wrapper.component.html' +}) +export class CopyThingsDialogWrapperComponent { + @Input() thingType: string; + @Input() copies: IdlObject[] = []; + @Input() copyIds: number[] = []; + @Input() batchWarningMessage: string; + @Input() inBatch: () => boolean; + @Input() onClose: () => void; + @Input() onApplyChanges: () => void; + + constructor() {} +} diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-things-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-things-dialog.component.html new file mode 100644 index 0000000000..af87dd1df5 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-things-dialog.component.html @@ -0,0 +1,44 @@ +<!-- +<eg-string #successMsg [text]="successMessage" i18n-text></eg-string> +<eg-string #errorMsg [text]="errorMessage" i18n-text></eg-string> +--> +<ng-template #dialogContent> + <div class="modal-header"> + <h4 class="modal-title"> + <span *ngIf="!inBatch()" i18n>Managing {{thingType}} for item {{copies.length ? copies[0].barcode() : ''}}</span> + <span *ngIf="inBatch()" i18n>Managing {{thingType}} in common for {{copyIds.length}} item(s)</span> + </h4> + <button type="button" class="btn-close btn-close-white" + i18n-aria-label aria-label="Close dialog" (click)="close()"> + </button> + </div> + + <div class="modal-body p-4 form-validated"> + <!-- Batch warning message if needed --> + <div *ngIf="inBatch() && batchWarningMessage" class="alert alert-info"> + {{batchWarningMessage}} + </div> + + <!-- Existing items section --> + <ng-container *ngTemplateOutlet="existingThings"> + </ng-container> + + <!-- Pending new items section --> + <h4 class="mt-3" i18n>Pending New {{thingType}}</h4> + <ng-container *ngTemplateOutlet="pendingThings"> + </ng-container> + + <!-- New item form --> + <div class="row mt-2"> + <ng-container *ngTemplateOutlet="newThingForm"> + </ng-container> + </div> + </div> + + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" + (click)="close()" i18n>Close</button> + <button type="button" class="btn btn-success me-2" + (click)="applyChanges()" i18n>Apply Changes</button> + </div> +</ng-template> diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-things-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-things-dialog.component.ts new file mode 100644 index 0000000000..adfc1c110b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/copy-things-dialog.component.ts @@ -0,0 +1,537 @@ +/* eslint-disable max-len */ +import { Component, Input, ViewChild, TemplateRef, Optional, Inject, InjectionToken } from '@angular/core'; +import { lastValueFrom, Observable, throwError, from } from 'rxjs'; +import { tap, defaultIfEmpty, switchMap } from 'rxjs/operators'; +import { IdlService, IdlObject } from '@eg/core/idl.service'; +import { ToastService } from '@eg/share/toast/toast.service'; +import { AuthService } from '@eg/core/auth.service'; +import { PcrudService } from '@eg/core/pcrud.service'; +import { OrgService } from '@eg/core/org.service'; +import { StringComponent } from '@eg/share/string/string.component'; +import { DialogComponent } from '@eg/share/dialog/dialog.component'; +import { NgbModal, NgbModalOptions } from '@ng-bootstrap/ng-bootstrap'; + +/** + * Base interface for methods we expect on all thing objects + */ +export interface IThingObject extends IdlObject { + id(val?: number): number; + isnew(val?: boolean): boolean; + ischanged(val?: boolean): boolean; + isdeleted(val?: boolean): boolean; +} + +/** + * Interface for tracking thing changes + * Each implementation will extend this with its specific types + */ +export interface IThingChanges<T extends IThingObject> { + newThings: T[]; + changedThings: T[]; + deletedThings?: T[]; +} + +/** + * Configuration options for thing types + */ +export interface IThingConfig<T extends IThingObject> { + idlClass: string; + thingField: string; // Field name on copy object (e.g., 'copy_alerts') + fleshDepth?: number; + fleshFields?: {[key: string]: any}; + defaultValues?: {[key: string]: any}; +} + +// voodoo; thanks prod build +export const THINGDATA_CONFIG = new InjectionToken<IThingConfig<any>>('THINGDATA_CONFIG'); + +/** + * Base component for managing copy things + * T = The thing type (Alert, Note, Tag) + * C = The changes tracking type + */ +@Component({ + templateUrl: './copy-things-dialog.component.html' +}) +export abstract class CopyThingsDialogComponent< + T extends IThingObject, + C extends IThingChanges<T> +> extends DialogComponent { + + @Input() copyIds: number[] = []; + @Input() copies: IdlObject[] = []; // Pre-loaded copies from parent + @Input() inPlaceCreateMode = false; + @Input() templateOnlyMode = false; + + // Change tracking collections - can be pre-populated by parent + @Input() newThings: T[] = []; + @Input() changedThings: T[] = []; + @Input() deletedThings: T[] = []; + + protected copy: IdlObject; // Current copy in single mode + autoId = -1; + + // Template support properties + protected abstract thingType: string; + protected abstract successMessage: string; + protected abstract errorMessage: string; + protected abstract batchWarningMessage: string; + + @ViewChild('dialogContent') dialogContent: TemplateRef<any>; + @ViewChild('existingThings') existingThings: TemplateRef<any>; + @ViewChild('pendingThings') pendingThings: TemplateRef<any>; + @ViewChild('newThingForm') newThingForm: TemplateRef<any>; + + @ViewChild('successMsg', { static: true }) + protected successMsg: StringComponent; + + @ViewChild('errorMsg', { static: true }) + protected errorMsg: StringComponent; + + // protected config: IThingConfig<T>; + + constructor( + protected modal: NgbModal, + protected toast: ToastService, + protected idl: IdlService, + protected pcrud: PcrudService, + protected org: OrgService, + protected auth: AuthService, + @Optional() @Inject(THINGDATA_CONFIG) protected config?: IThingConfig<T> + ) { + super(modal); + this.config = config; + } + + /** + * Initialize component state + * Override in child classes for additional initialization + */ + protected async initialize(): Promise<void> { + if (this.templateOnlyMode) {return;} + + // defense: make sure both .copyIds and .copies contain things + // console.debug(`CopyThingsDialog(${this.thingType}): starting with this.copies, this.copyIds`, this.copies, this.copyIds); + if (!this.hasCopy()) { + // console.debug(`CopyThingsDialog(${this.thingType}): setting this.copies = []`); + this.copies = []; + } + if (!this.copyIds) { + // console.debug(`CopyThingsDialog(${this.thingType}): setting this.copyIds = []`); + this.copyIds = []; + } + + if (this.hasCopy() && !this.copyIds.length) { + this.copyIds = this.copies.map(c => c.id()); + // console.debug(`CopyThingsDialog(${this.thingType}): mapped this.copies to this.copyIds`, this.copyIds); + } + if (this.copyIds.length && !this.hasCopy()) { + await this.fetchCopies(); + // console.debug(`CopyThingsDialog(${this.thingType}): fetched copies for this.copies`, this.copies); + } + if (!this.hasCopy()) { + // if no copies at this point, it's an error + // console.error('No copies to work with. this.copies, this.copyIds', this.copies, this.copyIds); + } + + await this.initializeCopies(); + + if (this.copies.length >= 1) { + this.copy = this.copies[0]; + } + + return; + } + + /** + * Opens the dialog with appropriate setup + */ + open(args: NgbModalOptions): Observable<C> { + if (this.copyIds.length === 0 && !this.copies.length) { + console.error(`CopyThingsDialog(${this.thingType}): No copies provided`); + return throwError('No copies provided'); + } + + const obs = from(this.initialize()); + return obs.pipe(switchMap(() => super.open(args))); + } + + /** + * Fetch copies from database when not pre-loaded + */ + protected async fetchCopies(): Promise<void> { + if (!this.copyIds && !this.copyIds.length) { + console.error(`CopyThingsDialog(${this.thingType}): no this.copyIds for fetchCopies`); + } + const ids = this.copyIds.filter(id => id > 0); + if (ids.length === 0) { + console.error(`CopyThingsDialog(${this.thingType}): no copies fetched for this.copies. this.copyids =`, this.copyIds); + return; + } + + const searchOpts: any = {}; + if (this.config.fleshFields) { + searchOpts.flesh = this.config.fleshDepth; + searchOpts.flesh_fields = this.config.fleshFields; + } + const reqOpts: any = { atomic: true }; + + const result = await this.pcrud.search('acp', + { id: ids }, + searchOpts, + reqOpts + ).toPromise(); + + if (typeof result.length === 'undefined') { + this.copies = [ result ]; // single + } else { + this.copies = result; // multiple + } + + if (!this.copies && !this.copies.length) { + console.error(`CopyThingsDialog(${this.thingType}): pcrud did not find copies with ids from this.copyIds`, this.copyIds); + } + } + + /** + * Initialize thing arrays on copies if needed + */ + protected async initializeCopies(): Promise<void> { + /* + console.debug(`CopyThingsDialog(${this.thingType}): initializeCopies(), this.copies, + this.copyIds`, this.copies, this.copyIds); + /** */ + if (!this.copies) { + console.error(`CopyThingsDialog(${this.thingType}): initializeCopies(), + nothing in this.copies. this.copies, this.copyIds`, this.copies, this.copyIds); + return; + } + this.copies.forEach(copy => { + const field = this.config.thingField; + // Ensure we have an array + if (!Array.isArray(copy[field]())) { + copy[field]([]); + } + }); + + /* + console.debug(`CopyThingsDialog(${this.thingType}): initializeCopies(); + this.inPlaceCreateMode, this.inBatch()`, this.inPlaceCreateMode, this.inBatch()); + /** */ + + // Re-fetch thing data, even if not needed, for simplicty and single + // source of truth for pre-filtering ah, but not single source of truth, + // with our fetchCopies elsewhere fleshing these things. so if needed + // for performance, maybe wrap this back into an !inPlacecopies test + // This is also mucking with our pending changes when the dialog is + // reinvoked before save; hrmm. clones/new-instances vs references + /* + console.debug(`CopyThingsDialog(${this.thingType}): initializeCopies(); + calling this.getThings`); + /** */ + await this.getThings(); + + // Process batch things if needed + if (this.inBatch()) { + /* + console.debug(`CopyThingsDialog(${this.thingType}): initializeCopies(); + calling this.processCommonThings`); + /** */ + await this.processCommonThings(); + } + } + + /** + * Create a new thing + */ + protected createNewThing(): T { + const thing = this.idl.create(this.config.idlClass) as T; + thing.id(this.autoId--); + thing.isnew(true); + + if (this.config.defaultValues) { + Object.entries(this.config.defaultValues).forEach(([key, value]) => { + thing[key](value); + }); + } + + return thing; + } + + /** + * Fetch things for the copies + * Must be implemented by child classes + */ + protected abstract getThings(): Promise<void>; + + /** + * Process common things for batch operations + * Override in child classes as needed + */ + protected abstract processCommonThings(): Promise<void>; + + /** + * Match things for batch operations + * Must be implemented by child classes + */ + protected abstract compositeMatch(a: T, b: T): boolean; + + /** + * Validate current state + * Override in child classes as needed + */ + protected validate(): boolean { + return true; + } + + /** + * Apply changes to copies + * Must be implemented by child classes + */ + protected abstract applyChanges(): Promise<void>; + + /** + * Utility methods + */ + protected hasCopy(): boolean { + return this.copies && this.copies.length > 0; + } + + protected inBatch(): boolean { + return this.copies &&this.copies.length > 1; + } + + /** + * Clear pending changes + */ + clearPending(): void { + this.newThings = []; + this.changedThings = []; + this.deletedThings = []; + } + + /** + * Toast message handling + */ + protected async showSuccess(): Promise<void> { + const msg = await this.successMsg.current(); + this.toast.success(msg || this.successMessage); + } + + protected async showError(err: any): Promise<void> { + const msg = await this.errorMsg.current(); + this.toast.danger(msg || this.errorMessage); + console.error(`Error in ${this.thingType} operation:`, err); + } + + /** + * Package changes for return to caller + */ + protected gatherChanges(): C { + const changes = { + newThings: this.newThings, + changedThings: this.changedThings, + deletedThings: this.deletedThings + } as C; + // console.debug('gatherChanges', changes); + return changes; + } + + /** + * Apply changes to copies and mark as changed + */ + protected markCopiesChanged(): void { + this.copies.forEach(copy => copy.ischanged(true)); + } + + + /** + * Remove in-memory things and flag in-db things for deletion, or act as a toggle + */ + removeThing(things: T[]): void { + /* + console.debug(`CopyThingsDialog(${this.thingType}): removeThing: things`, + this.idl.clone(things)); + console.debug(`CopyThingsDialog(${this.thingType}): removeThing: + incoming this.newThings`, this.newThings.length, this.idl.clone(this.newThings)); + console.debug(`CopyThingsDialog(${this.thingType}): removeThing: + incoming this.changedThings`, this.changedThings.length, this.idl.clone(this.changedThings)); + console.debug(`CopyThingsDialog(${this.thingType}): removeThing: + incoming this.deletedThings`, this.deletedThings.length, this.idl.clone(this.deletedThings)); + /** */ + things.forEach(thing => { + /* + console.debug(`CopyThingsDialog(${this.thingType}): removeThing: + considering thing with id, isnew, ischanged, isdeleted`, this.idl.clone(thing), + thing?.id(), thing?.isnew(), thing?.ischanged(), thing?.isdeleted()); + /** */ + if (thing === undefined) { + console.error('removeThing: What? Why? How? ^'); + } + + // considering this.newThings + if (this.newThings.find(t => t.id() === thing.id())) { + // console.debug('removeThing: thing to be removed found in this.newThings'); + if (thing.id() < 0) { + if (thing?.isnew() ?? false) { + console.debug('removeThing: isnew() is true, removing from this.newThings. removing'); + } else { + console.error('removeThing: isnew() is false, yet found in this.newThings. removing'); + } + } else { + console.error('removeThing: id() not negative, yet found in this.newThings. removing'); + } + this.newThings = this.newThings.filter(t => t.id() !== thing.id()); + } else { + console.debug('removeThing: thing to be removed not found in this.newThings'); + + if (thing.id() < 0) { + console.error('removeThing: thing has isnew() = true, so why not in newThings?'); + } + + // considering this.changedThings + if (this.changedThings.find(t => t.id() === thing.id())) { + console.warn('removeThing: thing to be removed found in this.changedThings. Removing from this.changedThings.'); + this.changedThings = this.changedThings.filter(t => t.id() !== thing.id()); + } + + // considering this.deletedThings + if (this.deletedThings.find(t => t.id() === thing.id())) { + console.debug('removeThing: thing to be removed already found in this.deletedThings'); + if (thing?.isdeleted() ?? false) { + console.log('removeThing: undeleting thing and removing from this.deletedThings'); + this.deletedThings = this.deletedThings.filter(t => t.id() !== thing.id()); + thing.isdeleted(false); + if (thing?.isnew() ?? false) { + console.error('removeThing: undeleted thing has isnew = true, putting into newThings'); + this.newThings.push(thing); + } else if (thing?.ischanged() ?? false) { + console.warn('removeThing: undeleted thing has ischanged = true, putting into changedThings'); + this.changedThings.push(thing); + } + } else { + console.error('removeThing: thing to be removed has isdeleted = false; why is it already in deletedThings? setting isdeleted(true)'); + thing.isdeleted(true); + } + } else { + console.debug('removeThing: thing to be removed not already found in this.deletedThings'); + if (thing?.isdeleted() ?? false) { + console.error('removeThing: thing to be removed has isdeleted = true. Why not already in deletedThings? Will "undelete"'); + } else { + console.log('removeThing: setting thing to isdeleted(true) and putting into deletedThings'); + thing.isdeleted(true); + this.deletedThings.push(thing); + } + } + } + }); + // console.debug(`CopyThingsDialog(${this.thingType}): removeThing: outgoing this.newThings`, this.newThings.length, this.newThings); + // console.debug(`CopyThingsDialog(${this.thingType}): removeThing: outgoing this.changedThings`, this.changedThings.length, this.changedThings); + // console.debug(`CopyThingsDialog(${this.thingType}): removeThing: outgoing this.deletedThings`, this.deletedThings.length, this.deletedThings); + } + + /** + * Save changes directly to database + * Only called when not in inPlaceCreateMode + */ + protected async saveChanges(): Promise<boolean> { + const pendingDeletions = []; + const pendingUpdates = []; + this.copies.forEach( c=> { + const things = c[this.config.thingField](); + things.forEach( t=> { + if (t.isdeleted()) { + pendingDeletions.push(t); + } else if (t.isnew() || t.ischanged()) { + pendingUpdates.push(t); + } + }); + }); + // console.debug(`CopyThingsDialog(${this.thingType}), saveChanges, pendingDeletions, pendingUpdates`, pendingDeletions, pendingUpdates); + + // Validate required fields + for (const thing of pendingUpdates) { + if (!thing.classname || !this.idl.classes[thing.classname]) { + throw new Error(`Hope not to ever see this: ${thing.classname}`); + } + + const requiredFields = this.idl.classes[thing.classname].fields + .filter(field => field.required) + .map(field => field.name); + + for (const fieldName of requiredFields) { + if (thing[fieldName]() === null || thing[fieldName]() === undefined) { + throw new Error( + `Required field "${fieldName}" is not set for changed ${thing.classname} object` + ); + } + } + } + + let resp = false; + // Handle deletions first if supported + if (pendingDeletions.length > 0) { + try { + resp = await lastValueFrom( + this.pcrud.remove(pendingDeletions) + .pipe( + tap({ + next: (val) => console.debug(`CopyThingsDialog(${this.thingType}), saveChanges, pcrud.remove next`, val), + error: (err: unknown) => console.error(`CopyThingsDialog(${this.thingType}), saveChanges, pcrud.remove err`, err), + complete: () => console.debug(`CopyThingsDialog(${this.thingType}), saveChanges, pcrud.remove completed`) + }), + defaultIfEmpty(null) + ) + ); + if (!resp) { + // console.debug(`CopyThingsDialog(${this.thingType}), saveChanges, pcrud.remove returned null, early abort`); + return false; + } + } catch(E) { + console.error(`CopyThingsDialog(${this.thingType}), saveChanges, pcrud.remove error`, E); + return false; + } + } + + try { + if (pendingUpdates.length > 0) { + const resp2 = await lastValueFrom( + this.pcrud.autoApply(pendingUpdates) + .pipe( + tap({ + next: (val) => console.debug(`CopyThingsDialog(${this.thingType}), saveChanges, pcrud.autoApply next`, val), + error: (err: unknown) => console.error(`CopyThingsDialog(${this.thingType}), saveChanges, pcrud.autoApply err`, err), + complete: () => console.debug(`CopyThingsDialog(${this.thingType}), saveChanges, pcrud.autoApply completed`) + }), + defaultIfEmpty(null) + ) + ); + // console.debug(`CopyThingsDialog(${this.thingType}), saveChanges, pcrud.autoApply response`, resp2); + if (resp2) { + this.clearPending(); + } + // console.debug(`CopyThingsDialog(${this.thingType}), saveChanges(), resp2`, resp2); + return resp2 ? true : false; + } else { + // console.debug(`CopyThingsDialog(${this.thingType}), saveChanges(), resp`, resp); + return resp ? true : false; + } + } catch(E) { + console.error(`CopyThingsDialog(${this.thingType}), saveChanges, pcrud.autoApply error`, E); + return false; + } + } + + /** + * Template context helper + */ + protected getTemplateContext() { + return { + $implicit: this, + copies: this.copies, + copyIds: this.copyIds, + inBatch: () => this.inBatch(), + thingType: this.thingType + }; + } +} diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts index cdd9694f16..7f6497799a 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.module.ts @@ -5,8 +5,9 @@ import {HoldingsService} from './holdings.service'; import {MarkDamagedDialogComponent} from './mark-damaged-dialog.component'; import {MarkMissingDialogComponent} from './mark-missing-dialog.component'; import {MarkDiscardDialogComponent} from './mark-discard-dialog.component'; -import {CopyAlertsDialogComponent} from './copy-alerts-dialog.component'; +import {CopyAlertsDialogComponent, AlertTypeValidatorDirective} from './copy-alerts-dialog.component'; import {CopyTagsDialogComponent} from './copy-tags-dialog.component'; +import {TagMapListComponent} from './tag-map-list.component'; import {CopyNotesDialogComponent} from './copy-notes-dialog.component'; import {ReplaceBarcodeDialogComponent} from './replace-barcode-dialog.component'; import {DeleteHoldingDialogComponent} from './delete-volcopy-dialog.component'; @@ -14,7 +15,9 @@ import {ConjoinedItemsDialogComponent} from './conjoined-items-dialog.component' import {TransferItemsComponent} from './transfer-items.component'; import {TransferHoldingsComponent} from './transfer-holdings.component'; import {BatchItemAttrComponent} from './batch-item-attr.component'; +import {CopyThingsDialogWrapperComponent} from './copy-things-dialog-wrapper.component'; import {CopyAlertManagerDialogComponent} from './copy-alert-manager.component'; +import {CopyAlertsPageComponent} from './copy-alerts-page.component'; import {CopyNotesEditComponent} from './copy-notes-edit/copy-notes-edit.component'; import { FmRecordEditorModule } from '@eg/share/fm-editor/fm-editor.module'; @@ -23,8 +26,10 @@ import { FmRecordEditorModule } from '@eg/share/fm-editor/fm-editor.module'; MarkDamagedDialogComponent, MarkMissingDialogComponent, MarkDiscardDialogComponent, + CopyThingsDialogWrapperComponent, CopyAlertsDialogComponent, CopyTagsDialogComponent, + TagMapListComponent, CopyNotesDialogComponent, CopyNotesEditComponent, ReplaceBarcodeDialogComponent, @@ -33,7 +38,9 @@ import { FmRecordEditorModule } from '@eg/share/fm-editor/fm-editor.module'; TransferItemsComponent, TransferHoldingsComponent, BatchItemAttrComponent, - CopyAlertManagerDialogComponent + CopyAlertManagerDialogComponent, + CopyAlertsPageComponent, + AlertTypeValidatorDirective ], imports: [ StaffCommonModule, @@ -46,6 +53,7 @@ import { FmRecordEditorModule } from '@eg/share/fm-editor/fm-editor.module'; MarkDiscardDialogComponent, CopyAlertsDialogComponent, CopyTagsDialogComponent, + TagMapListComponent, CopyNotesDialogComponent, ReplaceBarcodeDialogComponent, DeleteHoldingDialogComponent, @@ -53,7 +61,8 @@ import { FmRecordEditorModule } from '@eg/share/fm-editor/fm-editor.module'; TransferItemsComponent, TransferHoldingsComponent, BatchItemAttrComponent, - CopyAlertManagerDialogComponent + CopyAlertManagerDialogComponent, + CopyAlertsPageComponent ], providers: [ HoldingsService diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts index d529bd72cc..7114673694 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/holdings.service.ts @@ -63,7 +63,8 @@ export class HoldingsService { } setTimeout(() => { const tab = hideVols ? 'attrs' : 'holdings'; - const url = `/eg2/staff/cat/volcopy/${tab}/session/${key}`; + let url = `/eg2/staff/cat/volcopy/${tab}/session/${key}`; + if (recordId !== null) {url += `?record_id=${recordId}`;} window.open(url, '_blank'); }); }); diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/tag-map-list.component.css b/Open-ILS/src/eg2/src/app/staff/share/holdings/tag-map-list.component.css new file mode 100644 index 0000000000..8a0370d680 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/tag-map-list.component.css @@ -0,0 +1,25 @@ +.tag-label { + font-size: 1rem; +} + +.tagmap-actions { + align-items: baseline; + display: flex; + gap: 0.75rem; + transition: opacity 0.5s ease-in-out; +} + +.tagmap-actions .edit-link:after { + display: inline; + content: "\e89e"; /* opens in new window icon */ + font-family: "Material Icons"; + line-height: inherit; +} + +::ng-deep .eg-grid-row:not(:has(:hover, :focus, :focus-visible)) .tagmap-actions { + opacity: 0 !important; +} + +kbd::first-letter { + text-transform: none; +} \ No newline at end of file diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/tag-map-list.component.html b/Open-ILS/src/eg2/src/app/staff/share/holdings/tag-map-list.component.html new file mode 100644 index 0000000000..7238b32068 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/tag-map-list.component.html @@ -0,0 +1,74 @@ +<ng-template #combinedTemplate let-tagMap="row"> + <div id="tagmap-{{tagMap.id()}}"> + <h3 class="tag-label" id="tag-label-{{tagMap.tag().id()}}">{{tagMap.tag().label()}}</h3> + <!-- <span class="badge badge-primary" i18n>New</span> --> + + <p class="text-muted">{{tagMap.tag().value()}}</p> + <p *ngIf="tagMap.tag().url()" class="mx-0 my-1"> + <a href="{{tagMap.tag().url()}}" target="_blank"><kbd>{{tagMap.tag().url()}}</kbd></a> + </p> + <p *ngIf="tagMap.tag().staff_note()" class="alert alert-primary px-2 py-1">{{tagMap.tag().staff_note()}}</p> + </div> + <div class="tagmap-actions"> + <a class="btn-link edit-link" target="_blank" routerLink="/staff/admin/local/asset/copy_tag" + [queryParams]='{gridFilters: "{\"id\":" + tagMap.tag().id() + "}" }' + [attr.aria-describedby]="'tag-label-' + tagMap.tag().id()" + title="Edit in Tag Administration screen" i18n-title i18n> + Edit Tag + </a> + <button *ngIf="!tagMap.isdeleted()" type="button" class="btn-link btn-destroy" + [attr.aria-describedby]="'tag-label-' + tagMap.tag().id()" + (click)="removeRow(tagMap, $event)" i18n> + Remove + </button> + <button *ngIf="tagMap.id() && tagMap.isdeleted()" type="button" class="btn-link btn-destroy" + [attr.aria-describedby]="'tag-label-' + tagMap.tag().id()" + (click)="removeRow(tagMap, $event)" i18n> + Restore + </button> + </div> +</ng-template> + +<ng-template #statusTemplate let-tagMap="row"> + <span *ngIf="!tagMap.id() || tagMap.id() < 0" class="badge badge-primary" i18n>New</span> + <span *ngIf="tagMap.isdeleted()" class="badge badge-danger" i18n>Deleted</span> +</ng-template> + +<ng-template #tagMapIds let-tagMap="row"> + {{ getTagMapIdsColumn(tagMap) }} +</ng-template> + +<h3 class="mt-4 border-bottom">Tags</h3> +<!-- +<p>Copies: {{copyIds | json}}</p> +<p>Existing tag maps: {{maps | json}}</p> +<p>New things: {{newThingIds | json}}</p> +<p>All tagMap IDs: {{mapIds | json}}</p> +<h3 class="mt-4 border-bottom">Tag Maps</h3> +--> +<eg-grid #tagMapGrid idlClass="acptcm" persistKey="holdings.copy_tags.tag_map_list" + [dataSource]="tagMapSource" [sortable]="true" [filterable]="false" + [cellTextGenerator]="tagMapCellTextGenerator"> + <eg-grid-toolbar-action label="Remove Selected" i18n-label + (onClick)="onRemove($event)" [disableOnRows]="noSelectedTagMaps"> + </eg-grid-toolbar-action> + + <eg-grid-column path="id" i18n-label label="Tag Map ID (debug)" [index]="true" [hidden]="true" + [cellTemplate]="tagMapIds"></eg-grid-column> + <eg-grid-column path="tag" i18n-label label="Tag ID" [hidden]="true"></eg-grid-column> + <eg-grid-column path="copy" i18n-label label="Item ID" [hidden]="true"></eg-grid-column> + <eg-grid-column i18n-label label="Tag Label & Value" name="combined_label_value" + [cellTemplate]="combinedTemplate"> + </eg-grid-column> + <eg-grid-column path="tag.label" i18n-label label="Tag Label" [hidden]="true"></eg-grid-column> + <eg-grid-column path="tag.value" i18n-label label="Tag Value" [hidden]="true"></eg-grid-column> + <eg-grid-column path="tag.tag_type.label" i18n-label label="Type"></eg-grid-column> + <eg-grid-column path="tag.staff_note" [hidden]="true"></eg-grid-column> + <eg-grid-column path="tag.url" [hidden]="true"></eg-grid-column> + <eg-grid-column path="tag.owner" [hidden]="true"></eg-grid-column> + <eg-grid-column i18n-label label="Status" name="tagmap_status" + [cellTemplate]="statusTemplate"> + </eg-grid-column> + <eg-grid-column path="tag.pub" i18n-label label="OPAC Visible? (Value)"></eg-grid-column> + <eg-grid-column *ngIf="showIsDeleted" path="isdeleted" i18n-label label="Deleted?" [hidden]="true"></eg-grid-column> +</eg-grid> diff --git a/Open-ILS/src/eg2/src/app/staff/share/holdings/tag-map-list.component.ts b/Open-ILS/src/eg2/src/app/staff/share/holdings/tag-map-list.component.ts new file mode 100644 index 0000000000..2e3ed53f39 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/holdings/tag-map-list.component.ts @@ -0,0 +1,210 @@ +import { Component, OnInit, Input, Output, ViewChild, EventEmitter } from '@angular/core'; +import { firstValueFrom, Observable, from } from 'rxjs'; +import { OrgService } from '@eg/core/org.service'; +import { IdlObject, IdlService } from '@eg/core/idl.service'; +import { PcrudService } from '@eg/core/pcrud.service'; +import { BroadcastService } from '@eg/share/util/broadcast.service'; +import { GridComponent } from '@eg/share/grid/grid.component'; +import { GridDataSource, GridCellTextGenerator, GridColumnSort } from '@eg/share/grid/grid'; +import {Pager} from '@eg/share/util/pager'; + +@Component({ + selector: 'eg-tag-map-list', + templateUrl: './tag-map-list.component.html', + styleUrls: ['./tag-map-list.component.css'] +}) +export class TagMapListComponent implements OnInit { + @Input() maps: IdlObject[] = []; + @Input() newThings: IdlObject[] = []; + @Input() headerText: string; + @Input() buttonText: string; + @Input() code2cctt: {[id: string]: IdlObject}; + @Input() trickery: Function; + @Input() showIsDeleted?: boolean = false; + @Input() copyIds: number[] = []; + @Output() remove = new EventEmitter<any>(); + @Output() removeTag = new EventEmitter<any>(); + + @ViewChild('tagMapGrid', { static: false }) tagMapGrid: GridComponent; + @ViewChild('tagGrid', { static: false }) tagGrid: GridComponent; + tagMapSource: GridDataSource = new GridDataSource(); + tagSource: GridDataSource = new GridDataSource(); + tagMapCellTextGenerator: GridCellTextGenerator; + tagCellTextGenerator: GridCellTextGenerator; + + allTagMaps: IdlObject[] = []; + allTagIds: number[] = []; + mapIds: number[] = []; + newThingIds: number[] = []; + representativeTagMaps: IdlObject[] = []; + + // noSelectedRows: (rows: IdlObject[]) => boolean; + noSelectedTagMaps: (rows: IdlObject[]) => boolean; + noSelectedTags: (rows: IdlObject[]) => boolean; + + constructor( + private org: OrgService, + private idl: IdlService, + private pcrud: PcrudService, + private broadcaster: BroadcastService + ) {} + + ngOnInit() { + // console.debug('TagMapListComponent, ngOnInit, this', this); + if (this.trickery) { + this.trickery(this); + } + + this.broadcaster.listen('eg.acpt_updated').subscribe(async (data) => { + // console.debug('TagMapListComponent listener received',data); + if (typeof data.result === 'string' || typeof data.result == 'number') { + const flesh = { + flesh: 1, + flesh_fields: { + acpt: ['tag_type'] + } + }; + const actualTag = await firstValueFrom(this.pcrud.retrieve('acpt', data.result, flesh)); + // console.debug('TagMapListComponent actualTag', actualTag); + if (actualTag) { + let found = false; + this.allTagMaps.forEach( tagMap => { + if (tagMap.tag().id() === actualTag.id()) { + const isdeleted = tagMap.tag().isdeleted(); + actualTag.isdeleted( isdeleted ); + tagMap.tag( actualTag ); + found = true; + } + }); + if (found) { + this.tagMapGrid.reload(); + } + } + } + }); + + this.noSelectedTagMaps = (rows: IdlObject[]) => (rows.length === 0); + this.tagMapSource.getRows = (pager: Pager, sort: GridColumnSort[]): Observable<any> => { + console.error('TagMapListComponent, tagMapSource getRows called with maps, newThings', this.maps, this.newThings); + if ((!this.maps || !this.maps.length) && (!this.newThings || !this.newThings.length)) { + // console.debug('TagMapListComponent, no maps available yet'); + return from([]); // Return empty array if maps aren't loaded yet + } + + const allRows = this.getRows(); + + const startIndex = pager.offset; + const endIndex = startIndex + pager.limit; + const pagedRows = allRows.slice(startIndex, endIndex); + + // console.debug('TagMapListComponent, returning tagMapSource rows:', pagedRows); + return from(pagedRows); + }; + + this.tagMapCellTextGenerator = { + combined_label_value: row => this.getCombinedLabelValueText(row), + tagmap_status: row => this.getStatusText(row), + tagmap_ids: row => this.getTagMapIdsColumn(row) + }; + } + + getRows() { + this.maps = this.maps.filter(m => m.id() !== null); + this.newThings = this.newThings.filter(m => m.id() !== null); + + this.allTagMaps = []; + if (this.newThings && this.newThings.length) { + this.allTagMaps = this.newThings.filter(m => m.id() !== null); + } + this.allTagMaps = this.allTagMaps.concat(this.maps); + + this.mapIds = [...new Set(this.allTagMaps.map(m => m.id()))]; + this.allTagIds = this.getTagIdsFromMaps(this.allTagMaps); + // console.debug('allTagIds: ', this.allTagIds); + return this.copyIds.length > 1 ? this.getRepresentativeRows() : this.allTagMaps; + } + + getTagIdsFromMaps(tagMaps: IdlObject[]): number[] { + if (!tagMaps || !tagMaps.length) {return [];} + + const set = [...new Set(tagMaps.map(tagMap => tagMap.tag().id()))]; + // console.debug('getTagIdsFromMaps, ', tagMaps, set); + + return set; + } + + getTagMapIdsFromTag(tagId) { + const allMapIds = this.allTagMaps.filter(m => m.tag().id() === tagId).map(m => m.id()); + return [...new Set(allMapIds)]; + } + + getTagMapIdsColumn(tagMap) { + if (this.copyIds.length <= 1) { + return tagMap.id(); + } + + // return this.getTagMapIdsFromTag(tagMap.tag().id()).join(', '); + // in batch, let's not pretend we're showing real tagMap IDs, since + // they are not all set until volcopy.updateInMemoryCopyWithTags() runs + return '*'; + } + + getRepresentativeRows(): IdlObject[] { + const rows = []; + this.allTagIds.forEach(t => { rows.push(this.getRepresentativeTagMap(t)); }); + return rows; + } + + // In batch, all tagMaps for a given tag ID should have the same pending / deleted status + // In single or template, we have only one tagMap per tag ID anyway + getRepresentativeTagMap(tagId: number): IdlObject { + const firstMatchingMap = this.allTagMaps.find((m) => m.tag().id() === tagId); + // console.debug('First matching map for tag: ', tagId, firstMatchingMap); + return firstMatchingMap; + } + + getCombinedLabelValueText(tag: IdlObject): string { + return [tag.label(), tag.value()].join(' / '); + } + + getStatusText(tagMap: IdlObject): string { + if (tagMap.isdeleted()) { + return $localize`Deleted`; + } + + if (!tagMap.id() || this.newThings?.includes(tagMap.id())) { + return $localize`Pending`; + } + } + + reload(maps, newThings) { + // console.debug('tagMapGrid reload()', maps, newThings); + this.maps = maps; + this.newThings = newThings; + setTimeout( () => { + this.tagMapGrid.reload(); + }, 1 ); + } + + removeRow(map: any, $event: Event) { + $event.preventDefault(); + $event.stopPropagation(); + // in batch, removing one row should remove all other rows with the same tag ID + if (this.copyIds.length > 1) { + const selectedMaps = this.allTagMaps.filter(m => m.tag().id() === map.tag().id()); + const selectedMapIds = selectedMaps.map(m => m.id()); + // console.debug('Removing maps via single row action on one map in a batch: ', selectedMapIds); + this.remove.emit(selectedMaps); + } else { + // console.debug('Removing map via single row action: ', map); + this.remove.emit([map]); + } + // removeThing() will reload for us + // this.reload(); + } + + onRemove(selectedMaps: any) { + this.remove.emit(selectedMaps); + } + +} diff --git a/Open-ILS/web/js/ui/default/staff/cat/volcopy/app.js b/Open-ILS/web/js/ui/default/staff/cat/volcopy/app.js index e0c9722f5f..52bd79360e 100644 --- a/Open-ILS/web/js/ui/default/staff/cat/volcopy/app.js +++ b/Open-ILS/web/js/ui/default/staff/cat/volcopy/app.js @@ -2382,71 +2382,10 @@ function($scope , $q , $window , $routeParams , $location , $timeout , egCore , $scope.copy_alerts_dialog = function(copy_list) { if (!angular.isArray(copy_list)) copy_list = [copy_list]; - return $uibModal.open({ - templateUrl: './cat/volcopy/t_copy_alerts', - animation: true, - controller: - ['$scope','$uibModalInstance', - function($scope , $uibModalInstance) { - - itemSvc.get_copy_alert_types().then(function(ccat) { - $scope.alert_types = ccat; - }); - - $scope.focusNote = true; - $scope.copy_alert = { - create_staff : egCore.auth.user().id(), - note : '', - temp : false - }; - - egCore.hatch.getItem('cat.copy.alerts.last_type').then(function(t) { - if (t) $scope.copy_alert.alert_type = t; - }); - - if (copy_list.length == 1) { - $scope.copy_alert_list = copy_list[0].copy_alerts(); - } - - $scope.ok = function(copy_alert) { - - if (typeof(copy_alert.note) != 'undefined' && - copy_alert.note != '') { - angular.forEach(copy_list, function (cp) { - if (!angular.isArray(cp.copy_alerts())) cp.copy_alerts([]); - var a = new egCore.idl.aca(); - a.isnew(1); - a.create_staff(copy_alert.create_staff); - a.note(copy_alert.note); - a.temp(copy_alert.temp ? 't' : 'f'); - a.copy(cp.id()); - a.ack_time(null); - a.alert_type( - $scope.alert_types.filter(function(at) { - return at.id() == copy_alert.alert_type; - })[0] - ); - cp.copy_alerts().push( a ); - }); - - if (copy_alert.alert_type) { - egCore.hatch.setItem( - 'cat.copy.alerts.last_type', - copy_alert.alert_type - ); - } - - } - $uibModalInstance.close(); - } - - $scope.cancel = function($event) { - $uibModalInstance.dismiss(); - $event.preventDefault(); - } - }] - }); + // Instead of opening modal, open new tab with Angular route + const copyIds = copy_list.map(cp => cp.id()).join(','); + window.open(`/eg2/staff/cat/item/alerts?copyIds=${copyIds}`, '_blank'); } }]) diff --git a/Open-ILS/web/js/ui/default/staff/circ/services/circ.js b/Open-ILS/web/js/ui/default/staff/circ/services/circ.js index f032ed5d75..52024f5a9b 100644 --- a/Open-ILS/web/js/ui/default/staff/circ/services/circ.js +++ b/Open-ILS/web/js/ui/default/staff/circ/services/circ.js @@ -1674,19 +1674,15 @@ function($uibModal , $q , egCore , egAlertDialog , egConfirmDialog, egAddCopyAl } service.add_copy_alerts = function(item_ids) { - return egAddCopyAlertDialog.open({ - copy_ids : item_ids, - ok : function() { }, - cancel : function() {} - }).result.then(function() { }); + // Instead of opening modal, open new tab with Angular route + const copyIds = item_ids.join(','); + window.open(`/eg2/staff/cat/item/alerts?copyIds=${copyIds}`, '_blank'); } service.manage_copy_alerts = function(item_ids) { - return egCopyAlertEditorDialog.open({ - copy_id : item_ids[0], - ok : function() { }, - cancel : function() {} - }).result.then(function() { }); + // Instead of opening modal, open new tab with Angular route + const copyIds = item_ids.join(','); + window.open(`/eg2/staff/cat/item/alerts?copyIds=${copyIds}`, '_blank'); } // alert when copy location alert_message is set. commit a8ae0b8a15ffc2f22b48d75b2f4c1ad0af498890 Author: Stephanie Leary <stephanie.leary@equinoxoli.org> Date: Wed Mar 12 17:02:29 2025 +0000 LP2074112 IANT CSS alert links, button focus, validation Part of LP2074112 Item Alerts, Notes, Tags, and Templates Rework. Introduces styling for description (aka definition) lists used to display metadata key/value pairs. Updates styles for: * button hover and focus states * links in alert messages * checkbox input borders * form validation styles and error/success messages Signed-off-by: Stephanie Leary <stephanie.leary@equinoxoli.org> Signed-off-by: Jason Etheridge <jason@equinoxOLI.org> Signed-off-by: Terran McCanna <tmccanna@georgialibraries.org> diff --git a/Open-ILS/src/eg2/src/styles-colors.css b/Open-ILS/src/eg2/src/styles-colors.css index ca6ba199e0..2cb7d80dd5 100644 --- a/Open-ILS/src/eg2/src/styles-colors.css +++ b/Open-ILS/src/eg2/src/styles-colors.css @@ -169,6 +169,7 @@ --border: light-dark(var(--bs-gray-300), var(--bs-gray-700)); --border-thick: light-dark(var(--bs-gray-400), var(--bs-gray-600)); --inactive: light-dark(var(--bs-gray-800), var(--bs-gray-400)); + --form-control-border: light-dark(var(--bs-gray-500), var(--bs-gray-700)); --form-check-border: light-dark(var(--bs-gray-300), var(--bs-gray-800)); --bs-body-color: light-dark(var(--bs-gray-900), var(--bs-gray-400)); diff --git a/Open-ILS/src/eg2/src/styles.css b/Open-ILS/src/eg2/src/styles.css index f5a7759d14..377a76bd59 100644 --- a/Open-ILS/src/eg2/src/styles.css +++ b/Open-ILS/src/eg2/src/styles.css @@ -43,6 +43,36 @@ h5 {font-size: .95rem} .semibold {font-weight: 600;} output {padding: 0.5rem 0;} +dl { + margin-block: 1.5rem; +} + +.dl-grid { + display: grid; + gap: 0.65rem 1rem; + grid-template-columns: minmax(min-content, 10rem) 1fr; +} + +.dl-grid > div { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; +} + +.dl-grid > :is(dt, div > dt, .term, div > .term) { + grid-column: 1; +} + +.dl-grid > :is(dd, div > dd, .def, div > .def) { + grid-column: 2; +} + +/* align to the right */ +.dl-terms-end > :is(dt, div > dt, .term, div > .term), +.dl-defs-end > :is(dt, div > dt, .def, div > .def) { + text-align: end; +} + .numeric { font-variant-numeric: tabular-nums lining-nums; text-align: end; @@ -67,29 +97,30 @@ a, color: var(--primary); } -a:focus, -a:hover, -.nav-link:focus, -.nav-link:hover { +a:not(.alert-link):is(:hover, :focus, :focus-visible), +.nav-link:is(:hover, :focus, :focus-visible) { border-color: var(--primary-hover); color: var(--primary-hover); } .btn-link { + background-color: transparent; + border-width: 0; color: var(--primary); padding: inherit; } -.btn-link:focus, -.btn-link:hover, -.nav-link:focus, -.nav-link:hover { +.btn-link:is(:hover, :focus, :focus-visible), +.nav-link:is(:hover, :focus, :focus-visible) { color: var(--primary-hover); } -.btn-link:focus, -.btn-link:hover { - border-width: 0; +.alert-link { + text-decoration: underline; +} + +.alert-link:is(:hover, :focus, :focus-visible) { + filter: brightness(1.1); } /** BS has flex utility classes, but none for specifying flex widths. @@ -186,6 +217,18 @@ a:hover, align-items: center; } +.btn:has(.material-icons) { + align-items: center; + display: inline-flex; + flex-wrap: wrap; + gap: 0.25rem; + line-height: inherit; +} + +.btn .material-icons { + font-size: 1.2rem; +} + /* dropdown menu link/button with no downward carrot icon */ .no-dropdown-caret::after { display: none; @@ -231,14 +274,60 @@ a:hover, * Required valid fields are left-border styled in green-ish. * Invalid fields are left-border styled in red-ish. */ -.form-validated .ng-valid[required]:not(eg-combobox):not(eg-date-select), -.form-validated .ng-valid.required, input[formcontrolname].ng-valid.required { + +.form-validated .ng-valid[required]:not(:is(fieldset, form)), +.form-validated .ng-valid.required, +.form-validated input[formcontrolname].ng-valid.required { border-left: 5px solid var(--bs-form-valid-border-color); + animation-name: fadeInBorder; + animation-iteration-count: 1; + animation-timing-function: ease-in; + animation-duration: 0.35s; } -.form-validated .ng-invalid:not(form):not(eg-combobox):not(eg-date-select), -input[formcontrolname].ng-invalid, -.invalid { + +.form-validated .ng-invalid:not(:is(fieldset, form)), +.form-validated input[formcontrolname].ng-invalid, +.form-validated .invalid:not(:is(form)) { border-left: 5px solid var(--bs-form-invalid-border-color); + animation-name: fadeInBorder; + animation-iteration-count: 1; + animation-timing-function: ease-in; + animation-duration: 0.35s; +} + +.form-control.ng-valid.ng-touched ~ .invalid-feedback { + display: none; +} + +.valid-feedback, +.invalid-feedback { + display: block; + margin: 0; + opacity: 1; + animation-name: fadeInOpacity; + animation-iteration-count: 1; + animation-timing-function: ease-in; + animation-duration: 0.35s; +} + +@keyframes fadeInOpacity { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +@keyframes fadeInBorder { + 0% { + border-left-color: var(--border); + border-left-width: 1px; + } + 100% { + border-left-color: var(--bs-form-invalid-border-color); + border-left-width: 5px; + } } /* Typical form CSS. @@ -280,9 +369,16 @@ option[disabled] { color: light-dark(rgba(0,0,0, 0.3), rgba(255,255,255, 0.6)); } -.form-check-input[type=checkbox] { - border-width: 2px; - border-color: var(--form-check-border); +input, textarea, select, option, +.form-control, .form-control:focus, .form-select { + --bs-border-color: var(--form-control-border); + background-color: var(--bs-body-bg); + color: var(--bs-body-color); +} + +input[type="checkbox"], .form-check-input { + --bs-border-color: var(--form-check-border); + --bs-border-width: 2px; min-width: 1rem; min-height: 1rem; } @@ -290,10 +386,9 @@ option[disabled] { /* Bootstrap's focus "outline" is a faint box shadow. Buttons have color-specific shadows, so let's leave those alone except for ones that are dropdown items. */ -:not(button):not(.btn):focus, -:not(button):not(.btn):focus-visible, -button.dropdown-item:focus, -button.dropdown-item:focus-visible { +:not(button):not(.btn):is(:focus, :focus-visible), +.btn-link:is(:focus, :focus-visible), +button.dropdown-item:is(:focus, :focus-visible) { outline: 0.25rem solid rgba(var(--primary-focus-outline-rgb), 0.75); outline-offset: 0; --moz-outline-radius: 0.25rem; @@ -479,6 +574,10 @@ button.input-group-text.text-danger { color: var(--bs-body-color); } +.btn:is(:hover, :focus, :focus-visible) { + box-shadow: 0 0 0 .25rem rgba(var(--bs-blue-600-rgb), .25); +} + .btn.btn-secondary { color: var(--bs-btn-color); } @@ -487,11 +586,6 @@ button.input-group-text.text-danger { background-color: var(--btn-gray-bg); } -.btn.btn-link { - color: inherit; - background-color: transparent; -} - .btn-light:hover, .btn-light:focus, .show > .btn-light.dropdown-toggle { @@ -500,8 +594,7 @@ button.input-group-text.text-danger { border-color: light-dark(var(--bs-secondary), var(--bs-gray-100)); } -.btn-outline-secondary:hover, -.btn-outline-secondary:focus { +.btn-outline-secondary:is(:hover, :focus, :focus-visible) { color: light-dark(var(--bs-secondary), var(--bs-gray-200)); background-color: transparent; } @@ -515,7 +608,7 @@ button.input-group-text.text-danger { /* other colors' focus state shadows do not need to change */ .btn-warning.focus, -.btn-warning:focus { +.btn-warning:is(:hover, :focus, :focus-visible) { background-color: var(--badge-warning-hover-bg); border-color: var(--warning-border-hover); box-shadow: 0 0 0 .2rem var(--warning-shadow); @@ -527,10 +620,7 @@ button.input-group-text.text-danger { color: var(--bs-white); } -.btn-primary:hover, -.btn-primary.hover, -.btn-primary:focus, -.btn-primary.focus, +.btn-primary:is(.hover, :hover, .focus, :focus, :focus-visible), .btn-check:active + .btn-primary, .btn-check:checked + .btn-primary, .btn-primary.active, @@ -546,10 +636,7 @@ button.input-group-text.text-danger { color: var(--primary-border); } -.btn-outline-primary:hover, -.btn-outline-primary.hover, -.btn-outline-primary:focus, -.btn-outline-primary.focus { +.btn-outline-primary:is(.hover, :hover, .focus, :focus, :focus-visible) { background-color: var(--bs-body-bg); border-color: var(--primary-border); color: var(--primary-border); @@ -573,10 +660,7 @@ button.input-group-text.text-danger { color: white; } -.btn-info:hover, -.btn-info.hover, -.btn-info:focus, -.btn-info.focus, +.btn-info:is(.hover, :hover, .focus, :focus, :focus-visible), .btn-check:active + .btn-info, .btn-check:checked + .btn-info, .btn-info.active, @@ -596,10 +680,7 @@ button.input-group-text.text-danger { color: var(--bs-body-color); } -.btn-outline-info:hover, -.btn-outline-info.hover, -.btn-outline-info:focus, -.btn-outline-info.focus, +.btn-outline-info:is(.hover, :hover, .focus, :focus, :focus-visible), .btn-check:active+.btn-info, .btn-check:checked+.btn-info, .btn-info.active, @@ -630,10 +711,7 @@ button.input-group-text.text-danger { color: var(--success-color); } -.btn-success:hover, -.btn-success.hover, -.btn-success:focus, -.btn-success.focus, +.btn-success:is(.hover, :hover, .focus, :focus, :focus-visible), .btn-check:active + .btn-success, .btn-check:checked + .btn-success, .btn-success.active, @@ -649,10 +727,7 @@ button.input-group-text.text-danger { color: var(--success-color); } -.btn-outline-success:hover, -.btn-outline-success.hover, -.btn-outline-success:focus, -.btn-outline-success.focus { +.btn-outline-success:is(.hover, :hover, .focus, :focus, :focus-visible) { background-color: var(--bs-body-bg); border-color: var(--success-hover); color: var(--success-hover-color); @@ -678,24 +753,19 @@ button.input-group-text.text-danger { color: white; } -.btn-danger:hover, -.btn-danger:focus { +.btn-danger:is(.hover, :hover, .focus, :focus, :focus-visible) { background-color: var(--danger-bg-hover); } .btn-normal, .btn-destroy, -.btn-outline-normal:hover, -.btn-outline-normal:focus, -.btn-outline-normal:active { +.btn-outline-normal:is(:active, :hover, :focus, :focus-visible) { color: var(--btn-gray-color); background-color: var(--btn-gray-bg); border: 1px solid var(--btn-gray-border); } -.btn-normal:hover, -.btn-normal:focus, -.btn-normal:active { +.btn-normal:is(:active, :hover, :focus, :focus-visible) { color: var(--btn-gray-color); background-color: var(--btn-gray-bg-hover); border: 1px solid var(--btn-gray-border); @@ -708,16 +778,7 @@ button.input-group-text.text-danger { border: 1px solid var(--btn-gray-border); } -.btn-outline-normal:hover, -.btn-outline-normal:focus, -.btn-outline-normal:active { - color: var(--btn-gray-color); - background-color: var(--btn-gray-bg); - border: 1px solid var(--btn-gray-border); -} - -.btn-outline-normal:hover, -.btn-outline-normal:focus { +.btn-outline-normal:is(:active, :hover, :focus, :focus-visible) { color: var(--btn-gray-color); background-color: var(--btn-gray-bg); border: 1px solid var(--btn-gray-border); @@ -742,10 +803,8 @@ button.input-group-text.text-danger { border-color: transparent; } -.btn-destroy:hover, -.btn-destroy:focus, -.btn-link.btn-destroy:hover, -.btn-link.btn-destroy:focus { +.btn-destroy:is(:active, :hover, :focus, :focus-visible), +.btn-link.btn-destroy:is(:active, :hover, :focus, :focus-visible) { color: light-dark(var(--bs-red-600),var(--bs-red-200)); background-color: light-dark(var(--bs-gray-200), var(--bs-gray-900)); border: 1px solid var(--danger-border); @@ -764,16 +823,15 @@ button.input-group-text.text-danger { border: 1px solid var(--danger-border); } -.btn-outline-destroy:hover, -.btn-outline-destroy:focus { - color: var(--danger); +.btn-outline-destroy:is(:hover, :focus, :focus-visible) { + color: light-dark(var(--bs-red-600),var(--bs-red-200)); background-color: transparent; border: 1px solid var(--danger-border); box-shadow: 0 0 0 .25rem rgba(var(--bs-red-rgb), .25); } .btn-outline-destroy:active { - color: var(--danger); + color: light-dark(var(--bs-red-600),var(--bs-red-200)); background-color: transparent; border: 1px solid var(--danger-border); } @@ -796,12 +854,10 @@ button.focus-border:focus-visible { padding-top: .15em; } -.alert-primary, .badge-primary, .badge.text-bg-primary { background: var(--badge-bg); color: var(--badge-color); - border-color: var(---primary-border); } .badge-secondary, @@ -811,7 +867,6 @@ button.focus-border:focus-visible { border-color: var(--border); } -.alert-success, .badge-success, .badge.text-bg-success { background: var(--badge-success-bg); @@ -819,7 +874,6 @@ button.focus-border:focus-visible { border-color: var(--success-border); } -.alert-danger, .badge-danger, .badge.text-bg-danger { background: var(--badge-danger-bg); @@ -827,7 +881,6 @@ button.focus-border:focus-visible { border-color: var(--danger-border); } -.alert-warning, .badge-warning, .badge.text-bg-warning { background: var(--badge-warning-bg); @@ -835,7 +888,6 @@ button.focus-border:focus-visible { border-color: var(--warning-border); } -.alert-info, .badge-info, .badge.text-bg-info { background: var(--badge-info-bg); @@ -992,6 +1044,10 @@ background/text variables. Crash override. We can remove this in Bootstrap 5.3. color: var(--bs-body-color); } +.modal-body .list-group-item { + background-color: inherit; +} + .bg-light, .bg-white, [data-bs-theme="dark"] .btn-light:active, [data-bs-theme="dark"] .btn-outline-dark:active { @@ -1005,7 +1061,6 @@ legend, } .accordion-button:not(.collapsed), -.alert-info, .btn-light, .card-header, .eg-grid-header, @@ -1020,13 +1075,6 @@ legend, color: var(--bs-body-color); } -input, textarea, select, option, -.form-control, .form-control:focus, .form-select, .form-check-input { - background: var(--bs-body-bg); - border-color: light-dark(var(--bs-gray-500), var(--bs-gray-700)); - color: var(--bs-body-color); -} - .dropdown-item { display: block; width: 100%; @@ -1054,10 +1102,6 @@ input, textarea, select, option, color: light-dark(var(--bs-red-300), var(--bs-red-700)); } -[data-bs-theme="dark"] .alert { - border: none; -} - [data-bs-theme="dark"] .text-dark { color: var(--bs-body-color) !important; } commit dcb791d12d2fe58d2b8450944699883d5a8c925d Author: Stephanie Leary <stephanie.leary@equinoxoli.org> Date: Wed Mar 12 16:59:07 2025 +0000 LP2074112 IANT toasts should appear above dialogs Part of LP2074112 Item Alerts, Notes, Tags, and Templates Rework. Changes the z-index of toast messages to be higher than that of modal dialogs, so that toast notifications are not hidden by open dialogs. Signed-off-by: Stephanie Leary <stephanie.leary@equinoxoli.org> Signed-off-by: Jason Etheridge <jason@equinoxOLI.org> Signed-off-by: Terran McCanna <tmccanna@georgialibraries.org> diff --git a/Open-ILS/src/eg2/src/app/share/toast/toast.component.css b/Open-ILS/src/eg2/src/app/share/toast/toast.component.css index 1f70349f5c..901938313e 100644 --- a/Open-ILS/src/eg2/src/app/share/toast/toast.component.css +++ b/Open-ILS/src/eg2/src/app/share/toast/toast.component.css @@ -4,7 +4,7 @@ border-radius: 2px; padding: 10px; position: fixed; - z-index: 1; + z-index: 1500; /* ngbModal uses 1055 */ right: 15px; bottom: 5px; } commit ee21f8030ae2042db7543ffb3e1c526a775fd29e Author: Stephanie Leary <stephanie.leary@equinoxoli.org> Date: Wed Mar 12 16:53:08 2025 +0000 LP2074112 IANT file export generated file cleanup Part of LP2074112 Item Alerts, Notes, Tags, and Templates Rework. Explicitly revokes the generated URL for file downloads, to prevent users from receiving a cached file on subsequent attempts to download (for example, after changing the selected rows in a grid). Signed-off-by: Stephanie Leary <stephanie.leary@equinoxoli.org> Signed-off-by: Jason Etheridge <jason@equinoxOLI.org> Signed-off-by: Terran McCanna <tmccanna@georgialibraries.org> diff --git a/Open-ILS/src/eg2/src/app/share/util/file-export.service.ts b/Open-ILS/src/eg2/src/app/share/util/file-export.service.ts index 377b2bdb4d..0861f181c6 100644 --- a/Open-ILS/src/eg2/src/app/share/util/file-export.service.ts +++ b/Open-ILS/src/eg2/src/app/share/util/file-export.service.ts @@ -23,6 +23,7 @@ export class FileExportService { // the CSV download attributes / state. setTimeout(() => { this.resolver(); + (window.URL || window.webkitURL).revokeObjectURL(this.safeUrl.toString()); this.resolver = null; this.safeUrl = null; // eslint-disable-next-line no-magic-numbers commit 3ece778cdf5e325fb0b9bde3789d3661f5f9f4b4 Author: Stephanie Leary <stephanie.leary@equinoxoli.org> Date: Wed Mar 12 16:40:49 2025 +0000 LP2074112 IANT grid toolbar style; menu scrollbar Part of LP2074112 Item Alerts, Notes, Tags, and Templates Rework, but also addresses bug 2088007. Adds a button style directive for passing button classes to grid toolbar buttons. Prevents the grid settings menu from causing a double scrollbar (bug 2088007) and corrects the conditions for the empty styling to prevent extra whitespace below empty grids. Signed-off-by: Stephanie Leary <stephanie.leary@equinoxoli.org> Signed-off-by: Jason Etheridge <jason@equinoxOLI.org> Signed-off-by: Terran McCanna <tmccanna@georgialibraries.org> diff --git a/Open-ILS/src/eg2/src/app/share/common-widgets.module.ts b/Open-ILS/src/eg2/src/app/share/common-widgets.module.ts index d48b764c31..4bf68d5a96 100644 --- a/Open-ILS/src/eg2/src/app/share/common-widgets.module.ts +++ b/Open-ILS/src/eg2/src/app/share/common-widgets.module.ts @@ -8,6 +8,7 @@ import {CommonModule} from '@angular/common'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; import {EgCoreModule} from '@eg/core/core.module'; +import {ButtonStyleDirective} from '@eg/share/util/button-style.directive'; import {ComboboxComponent, IdlClassTemplateDirective} from '@eg/share/combobox/combobox.component'; import {ComboboxEntryComponent} from '@eg/share/combobox/combobox-entry.component'; import {DateSelectComponent} from '@eg/share/date-select/date-select.component'; @@ -25,6 +26,7 @@ import { CredentialInputComponent } from './util/credential-input.component'; @NgModule({ declarations: [ + ButtonStyleDirective, ComboboxComponent, ComboboxEntryComponent, DateSelectComponent, @@ -52,6 +54,7 @@ import { CredentialInputComponent } from './util/credential-input.component'; FormsModule, NgbModule, EgCoreModule, + ButtonStyleDirective, ComboboxComponent, ComboboxEntryComponent, DateSelectComponent, diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts index aeffb4722b..e773fc3999 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts @@ -45,6 +45,14 @@ export class GridBodyComponent { } onRowClick($event: any, row: any, idx: number) { + console.debug('GridBodyComponent, onRowClick:', { + target: $event.target, + currentTarget: $event.currentTarget, + clientX: $event.clientX, + clientY: $event.clientY, + row, + idx + }); this.handleRowClick($event, row); this.grid.onRowClick.emit(row); } diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-button.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-button.component.ts index 62ec95948f..c92c99bdb4 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-button.component.ts +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar-button.component.ts @@ -1,4 +1,5 @@ import {Component, Input, Output, OnInit, Host, TemplateRef, EventEmitter} from '@angular/core'; +import {ButtonStyle} from '@eg/share/util/button-style.directive'; import {GridToolbarButton} from './grid'; import {GridComponent} from './grid.component'; @@ -12,6 +13,9 @@ export class GridToolbarButtonComponent implements OnInit { // Note most input fields should match class fields for GridColumn @Input() label: string; + // Optional, for passing to egButtonStyle within the template + @Input() buttonStyle: ButtonStyle; + // These are optional labels that can come before and after the button @Input() adjacentPreceedingLabel = ''; @Input() adjacentSubsequentLabel = ''; @@ -53,6 +57,7 @@ export class GridToolbarButtonComponent implements OnInit { this.button.onClick = this.onClick; this.button.routerLink = this.routerLink; this.button.label = this.label; + this.button.buttonStyle = this.buttonStyle; this.button.adjacentPreceedingLabel = this.adjacentPreceedingLabel; this.button.adjacentSubsequentLabel = this.adjacentSubsequentLabel; if (this.adjacentPreceedingTemplateRef) { diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html index 3e88534866..91dad4baf5 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html @@ -42,7 +42,7 @@ </ng-container> </ng-container> <button - [disabled]="btn.disabled" type="button" + [disabled]="btn.disabled" type="button" [egButtonStyle]="btn.buttonStyle" class="input-group-text" (click)="performButtonAction(btn)"> {{btn.label}} </button> @@ -54,7 +54,8 @@ <!-- if no adjacent templates, print the button alone with the usual classes --> <button *ngIf="!btn.adjacentPreceedingTemplateRef && !btn.adjacentSubsequentTemplateRef" [disabled]="btn.disabled" type="button" - class="btn btn-outline-dark btn-sm" (click)="performButtonAction(btn)"> + class="btn btn-outline-dark btn-sm" [egButtonStyle]="btn.buttonStyle" + (click)="performButtonAction(btn)"> {{btn.label}} </button> @@ -148,7 +149,8 @@ <eg-grid-column-config #columnConfDialog [gridContext]="gridContext"> </eg-grid-column-config> - <div ngbDropdown class="btn-group eg-grid-settings" placement="bottom-right"> + <div ngbDropdown class="btn-group eg-grid-settings" placement="bottom-right" + (openChange)="gridContext.resizeWrapper = !gridContext.resizeWrapper"> <h2 class="visually-hidden" i18n>Grid Settings</h2> <button ngbDropdownToggle class="btn btn-outline-dark btn-sm no-dropdown-caret" type="button" title="Show Grid Options" i18n-title aria-label="Show Grid Options" i18n-aria-label> diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.ts b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.ts index 3059908b15..8f51aed114 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.ts +++ b/Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.ts @@ -76,8 +76,10 @@ export class GridToolbarComponent implements OnInit, AfterViewInit { // may offer to save to user/workstation OR org unit settings // depending on perms. - this.gridContext.saveGridConfig().catch( - err => console.error(`Error saving columns: ${err}`) + this.gridContext.saveGridConfig().then( + res => console.debug('this.gridContext.saveGridConfig', res) + ).catch( + err => console.error('Error saving columns:', err) ); } diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.component.css b/Open-ILS/src/eg2/src/app/share/grid/grid.component.css index bed69e0a95..09ba1dfaa8 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid.component.css +++ b/Open-ILS/src/eg2/src/app/share/grid/grid.component.css @@ -2,15 +2,14 @@ .eg-grid-wrapper { margin-bottom: 1rem; padding-bottom: 1rem; - overflow-x: hidden; - overflow-y: auto; - resize: horizontal; + overflow-x: clip; + overflow-y: visible; width: auto; } -/* Show just enough of the column config dropdown, if it exists */ -.eg-grid-wrapper:has(eg-grid-column-config) { - min-height: 300px; +.eg-grid-wrapper.eg-grid-resize { + overflow-y: auto; + resize: horizontal; } /* Not quite so loud on focus */ @@ -515,3 +514,10 @@ td.eg-grid-idlfield-email { font-size: 22px; font-weight: normal; } + +.eg-grid-cell-contents { + min-height: 1.5em; + height: 100%; + width: 100%; + display: block; +} diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.component.html b/Open-ILS/src/eg2/src/app/share/grid/grid.component.html index b6a0f99f66..69cf9e2347 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid.component.html +++ b/Open-ILS/src/eg2/src/app/share/grid/grid.component.html @@ -1,4 +1,6 @@ -<div id="{{gridDomId}}" class="eg-grid-wrapper col-12" role="region" aria-labelledby="eg-grid-caption" tabindex="0"> +<div id="{{gridDomId}}" class="eg-grid-wrapper col-12" + [ngClass]="{'eg-grid-resize': context.resizeWrapper}" + role="region" aria-labelledby="eg-grid-caption" tabindex="0"> <eg-grid-print #gridPrinter [gridContext]="context"> </eg-grid-print> @@ -22,7 +24,7 @@ <table #egGrid role="grid" tabindex="0" (keydown)="onGridKeyDown($event)" class="eg-grid table grid-key-{{persistKey ? persistKey.replaceAll('.', '_') : 'none'}}" [ngClass]="{'eg-grid-cell-truncate': context.truncateCells, - 'eg-grid-empty': !dataSource.requestingData && !dataSource.data.length, + 'eg-grid-empty': dataSource.data.length === 0, 'eg-grid-error': !dataSource.requestingData && dataSource.retrievalError, 'grid-density-wide': context.grid_density === 'wide', 'grid-density-compact': context.grid_density === 'compact' }"> diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.ts b/Open-ILS/src/eg2/src/app/share/grid/grid.ts index 228cba715e..a3f9ea14e1 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid.ts +++ b/Open-ILS/src/eg2/src/app/share/grid/grid.ts @@ -7,6 +7,7 @@ import {IdlService, IdlObject} from '@eg/core/idl.service'; import {OrgService} from '@eg/core/org.service'; import {ServerStoreService} from '@eg/core/server-store.service'; import {FormatService} from '@eg/core/format.service'; +import {ButtonStyle} from '@eg/share/util/button-style.directive'; import {Pager} from '@eg/share/util/pager'; import {GridFilterControlComponent} from './grid-filter-control.component'; @@ -721,6 +722,7 @@ export class GridContext { currentResizeCol: GridColumn; currentResizeTarget: any; grid_density: string; + resizeWrapper: boolean; // Allow calling code to know when the select-all-rows-in-page // action has occurred. @@ -752,6 +754,7 @@ export class GridContext { this.toolbarButtons = []; this.toolbarCheckboxes = []; this.toolbarActions = []; + this.resizeWrapper = true; } init() { @@ -1510,9 +1513,9 @@ export class GridContext { /* Base classes */ if (col.datatype) {classes.push('eg-grid-type-' + col.datatype);} - if (col.name) {classes.push('eg-grid-idlfield-' + col.name.replaceAll('.', '_'));} + if (col.name) {classes.push('eg-grid-idlfield-' + col.name?.replaceAll('.', '_'));} if (col.idlClass) {classes.push('eg-grid-idlclass-' + col.idlClass);} - if (col.path) {classes.push('eg-grid-path-' + col.path.replaceAll('.', '_'));} + if (col.path) {classes.push('eg-grid-path-' + col.path?.replaceAll('.', '_'));} /* TODO: pass idlclass to IDL service and find out whether this column is the primary key */ /* @@ -1521,7 +1524,7 @@ export class GridContext { */ /* Name-based formats */ - if (col.name.endsWith('count') || col.name.endsWith('Count')) {classes.push('numeric');} + if (col.name?.endsWith('count') || col.name?.endsWith('Count')) {classes.push('numeric');} switch (col.name) { case 'callnumber': @@ -1610,6 +1613,7 @@ export class GridToolbarButton { adjacentSubsequentLabel: string; adjacentPreceedingTemplateRef: TemplateRef<any>; adjacentSubsequentTemplateRef: TemplateRef<any>; + buttonStyle?: ButtonStyle; onClick: EventEmitter<any []>; action: () => any; // DEPRECATED disabled: boolean; diff --git a/Open-ILS/src/eg2/src/app/share/util/button-style.directive.ts b/Open-ILS/src/eg2/src/app/share/util/button-style.directive.ts new file mode 100644 index 0000000000..11e713e3f6 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/share/util/button-style.directive.ts @@ -0,0 +1,56 @@ +import { Directive, ElementRef, Input, Renderer2 } from '@angular/core'; + +export type ButtonStyle = { + primary?: boolean; + secondary?: boolean; + success?: boolean; + danger?: boolean; + warning?: boolean; + info?: boolean; + light?: boolean; + dark?: boolean; + link?: boolean; + outline?: boolean; +}; + +@Directive({ + selector: '[egButtonStyle]' +}) +export class ButtonStyleDirective { + @Input('egButtonStyle') set buttonStyle(value: ButtonStyle) { + this.updateClasses(value); + } + + private readonly buttonTypes = [ + 'primary', 'secondary', 'success', 'danger', + 'warning', 'info', 'light', 'dark', 'link', + 'normal', 'destroy' + ]; + + constructor(private el: ElementRef, private renderer: Renderer2) {} + + private updateClasses(style: ButtonStyle) { + // Always ensure the base 'btn' class is present + this.renderer.addClass(this.el.nativeElement, 'btn'); + + // Remove any existing button classes + this.buttonTypes.forEach(type => { + this.renderer.removeClass(this.el.nativeElement, `btn-${type}`); + this.renderer.removeClass(this.el.nativeElement, `btn-outline-${type}`); + }); + + // Apply the appropriate class based on the input + if (typeof style === 'object') { + for (const [key, value] of Object.entries(style)) { + if (value && this.buttonTypes.includes(key)) { + const prefix = style.outline ? 'btn-outline-' : 'btn-'; + this.renderer.addClass(this.el.nativeElement, `${prefix}${key}`); + break; // Only apply the first true value + } + } + } else { + // sane? somewhere along the line we made some buttons very plain and not obviously buttons + this.renderer.addClass(this.el.nativeElement, 'btn-normal'); + } + } +} commit 51d8f42543838ae583ee1e21daa44d7a41cdf4ff Author: Stephanie Leary <stephanie.leary@equinoxoli.org> Date: Wed Mar 12 16:32:35 2025 +0000 LP2074112 IANT dialog focus trap, restore on close Part of LP2074112 Item Alerts, Notes, Tags, and Templates Rework, but also addresses bug 1826584. Traps user focus in the topmost open dialog and automatically sets focus on the first focusable element in the dialog body (rather than the [X] close button in the header), or the primary footer button if the body contains no interactive elements. Also records the button used to open the dialog and returns focus to it when the dialog is closed. This code is adapted from newer versions of the ng-bootstrap modal component, but includes a more up to date focusable selector list. Signed-off-by: Stephanie Leary <stephanie.leary@equinoxoli.org> Signed-off-by: Jason Etheridge <jason@equinoxOLI.org> Signed-off-by: Terran McCanna <tmccanna@georgialibraries.org> diff --git a/Open-ILS/src/eg2/src/app/share/dialog/dialog.component.ts b/Open-ILS/src/eg2/src/app/share/dialog/dialog.component.ts index f8be6611ba..b4a37be97c 100644 --- a/Open-ILS/src/eg2/src/app/share/dialog/dialog.component.ts +++ b/Open-ILS/src/eg2/src/app/share/dialog/dialog.component.ts @@ -1,4 +1,5 @@ -import {Component, Input, OnInit, ViewChild, TemplateRef, EventEmitter} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; +import {Component, Input, OnInit, ViewChild, TemplateRef, EventEmitter, inject, ElementRef} from '@angular/core'; import {Observable, Observer} from 'rxjs'; import {NgbModal, NgbModalRef, NgbModalOptions} from '@ng-bootstrap/ng-bootstrap'; @@ -47,6 +48,9 @@ export class DialogComponent implements OnInit { @ViewChild('dialogContent', {static: false}) dialogContent: TemplateRef<any>; identifier: number = DialogComponent.counter++; + returnFocusTo: any; + private _document = inject(DOCUMENT); + private _elRef = inject(ElementRef<HTMLElement>); // Emitted after open() is called on the ngbModal. // Note when overriding open(), this will not fire unless also @@ -59,6 +63,8 @@ export class DialogComponent implements OnInit { // The modalRef allows direct control of the modal instance. protected modalRef: NgbModalRef = null; + public focusable: string; + constructor(private modalService: NgbModal) {} // Close all active dialogs @@ -73,6 +79,26 @@ export class DialogComponent implements OnInit { ngOnInit() { this.onOpen$ = new EventEmitter<any>(); + + const notFocusable = ':is(:disabled, [inert], [inert] *, [hidden], [hidden] *, [tabindex^="-"])'; + const isFocusable = [ + '[egAutofocus]', + '[ngbAutofocus]', + 'a[href]', + 'area[href]', + 'input:not([type="hidden"]):not(fieldset:disabled *)', + 'select:not(fieldset:disabled *)', + 'textarea:not(fieldset:disabled *)', + 'details > summary:first-of-type:not(details:not([open]) > details summary)', + 'details:not(:has(> summary)):not(details:not([open]) > details)', + 'button', + 'iframe', + 'audio[controls]', + 'video[controls]', + '[contenteditable]', + '[tabindex]' + ].join(', '); + this.focusable = `:is(${isFocusable}):not(${notFocusable})`; } open(options: NgbModalOptions = { backdrop: 'static' }): Observable<any> { @@ -87,10 +113,15 @@ export class DialogComponent implements OnInit { this.modalRef = this.modalService.open(this.dialogContent, options); DialogComponent.instances[this.identifier] = this; + this.returnFocusTo = this._document.activeElement; + // console.debug('this.returnFocusTo', this.returnFocusTo); if (this.onOpen$) { // Let the digest cycle complete - setTimeout(() => this.onOpen$.emit(true)); + setTimeout(() => { + this.onOpen$.emit(true); + this._setFocus(); + }); } return new Observable(observer => { @@ -109,6 +140,21 @@ export class DialogComponent implements OnInit { }); } + // Look for the first focusable element in .modal-body. + // If none, focus will default to the 'X' close button, if present, or the first footer button + private _setFocus() { + if (!this.modalRef) {return;} + if (!this._elRef.nativeElement.contains(this._document.activeElement)) { + const elementToFocus = this._elRef.nativeElement.querySelector('.modal-body ' + this.focusable) as HTMLElement; + // console.debug('elementToFocus', elementToFocus); + setTimeout(() => elementToFocus?.focus()); + } + } + + private _restoreFocus() { + setTimeout(() => this.returnFocusTo.focus()); + } + // Send a response to the caller without closing the dialog. respond(value: any) { if (this.observer && value !== undefined) { @@ -150,8 +196,7 @@ export class DialogComponent implements OnInit { } this.modalRef = null; delete DialogComponent.instances[this.identifier]; + this._restoreFocus(); } - } - commit d9ae26b82e8573474cc2e2f167e09c8cb45e06b1 Author: Stephanie Leary <stephanie.leary@equinoxoli.org> Date: Wed Mar 12 16:30:35 2025 +0000 LP2074112 IANT org-select form validation Part of LP2074112 Item Alerts, Notes, Tags, and Templates Rework, but also addresses bugs 1838784 and 2059142. Corrects form validation in the org-select component to use Angular Forms validity states rather than manipulating CSS classes directly. Signed-off-by: Stephanie Leary <stephanie.leary@equinoxoli.org> Signed-off-by: Jason Etheridge <jason@equinoxOLI.org> Signed-off-by: Terran McCanna <tmccanna@georgialibraries.org> diff --git a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html index b507163ac9..236ba0fa1f 100644 --- a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html +++ b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html @@ -8,7 +8,7 @@ <span>{{selected.label}}</span> </ng-container> -<ng-container *ngIf="!readOnly"> +<ng-container *ngIf="!readOnly" [formGroup]="orgSelectGroup"> <input type="text" class="form-control {{moreClasses}}" id="{{domId}}" @@ -29,6 +29,7 @@ (selectItem)="orgChanged($event)" container="body" #instance="ngbTypeahead" + formControlName="orgSelect" /> </ng-container> diff --git a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts index b9d58bb2a8..1f033be1ad 100644 --- a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts +++ b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts @@ -9,6 +9,7 @@ import {OrgService} from '@eg/core/org.service'; import {IdlObject} from '@eg/core/idl.service'; import {PermService} from '@eg/core/perm.service'; import {NgbTypeahead, NgbTypeaheadSelectItemEvent} from '@ng-bootstrap/ng-bootstrap'; +import {FormControl, FormGroup} from '@angular/forms'; /** Org unit selector * @@ -64,6 +65,7 @@ export class OrgSelectComponent implements OnInit, AfterViewInit { click$ = new Subject<string>(); valueFromSetting: number = null; sortedOrgs: IdlObject[] = []; + orgSelectGroup: FormGroup; // Disable the entire input @Input() disabled: boolean; @@ -202,6 +204,9 @@ export class OrgSelectComponent implements OnInit, AfterViewInit { private elm: ElementRef, ) { this.orgClassCallback = (orgId: number): string => ''; + this.orgSelectGroup = new FormGroup({ + orgSelect: new FormControl() + }); } ngOnInit() { @@ -346,17 +351,13 @@ export class OrgSelectComponent implements OnInit, AfterViewInit { } } - // Modifies the classlist of the input to show a visual change. - // FIXME I don't think angular forms notice this but I don't understand - // angular forms to do it properly :( updateValidity(newOrg: number) { if (newOrg && this.required) { - const node = document.getElementById(`${this.domId}`); + // console.debug('Checking org validity via FormControl', this.orgSelectGroup.controls.orgSelect); if (this.isValidOrg(newOrg)) { - node.classList.replace('ng-invalid', 'ng-valid'); - } else { - node.classList.replace('ng-valid', 'ng-invalid'); + return this.orgSelectGroup.controls.orgSelect.valid; } + return this.orgSelectGroup.controls.orgSelect.invalid; } } commit bc62c865946fe7e84d36cd98ca35d674deb06c8a Author: Stephanie Leary <stephanie.leary@equinoxoli.org> Date: Wed Mar 12 16:16:08 2025 +0000 LP2074112 IANT combobox, org-select keyboard support Part of LP2074112 Item Alerts, Notes, Tags, and Templates Rework, but also addresses bug 1826253. Adds event emitters to combobox and org-select components to support custom keyboard shortcuts. The Event key has its own emitter so that form submission functions can be called more easily; this can allow the Enter key to submit forms as normal input elements do. Also adds a passthrough for [ngbAutofocus] to allow the input to be focused automatically. Also adds an ariaDescribedby input to connect error messages to the input for screen reader use. In combobox, other minor improvements: * updated styling for freetext entries * set tabindex to -1 for disabled entries Signed-off-by: Stephanie Leary <stephanie.leary@equinoxoli.org> Signed-off-by: Jason Etheridge <jason@equinoxOLI.org> Signed-off-by: Terran McCanna <tmccanna@georgialibraries.org> diff --git a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.html b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.html index 43431b4822..ab2b6dc23a 100644 --- a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.html +++ b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.html @@ -27,10 +27,11 @@ [id]="domId" [attr.aria-labelledby]="ariaLabelledby" [ngClass]="{ - 'text-success fst-italic fw-bold': selected && selected.freetext, + 'text-success-emphasis fst-italic fw-bold': selected && selected.freetext, 'form-control-sm': smallFormControl }" [attr.aria-label]="ariaLabel" + [attr.aria-describedby]="ariaDescribedby" [placeholder]="placeholder" [name]="name" [disabled]="isDisabled" @@ -46,6 +47,7 @@ (focus)="onFocus($event)" container="body" (selectItem)="selectorChanged($event)" + [attr.ngbAutofocus]="ngbAutofocus" #instance="ngbTypeahead"/> <button *ngIf="icons" type="button" class="input-group-text" (click)="openMe($event)" aria-label="Open" i18n-aria-label title="Open" i18n-title> diff --git a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts index b8f1f26f7e..201a7ef6cf 100644 --- a/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts +++ b/Open-ILS/src/eg2/src/app/share/combobox/combobox.component.ts @@ -70,6 +70,7 @@ implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges { @Input() name: string; @Input() ariaLabel?: string = null; + @Input() ariaDescribedby?: string = null; // Placeholder text for selector input @Input() placeholder = ''; @@ -148,6 +149,7 @@ implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges { @Input() readOnly = false; @Input() focused = false; + @Input() ngbAutofocus = null; // passthrough for [ngbAutofocus] // Allow the selected entry ID to be passed via the template // This does NOT not emit onChange events. @@ -237,6 +239,14 @@ implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges { @Output() inputFocused: EventEmitter<void>; @Output() inputBlurred: EventEmitter<void>; + // Emitted when the Enter key is pressed in the input and the popup is not open + @Output() comboboxEnter = new EventEmitter<number>(); + + // Emitted when a key is pressed in the input and the popup is not open. + // A passthrough for keyboard events on the input. + // Example: (comboboxKeydown)="$event.key === 'Escape' ? cancel() : handleKeydown($event)" + @Output() comboboxKeydown = new EventEmitter<Event>(); + // Optionally provide an aria-labelledby for the input. This should be one or more // space-delimited ids of elements that describe this combobox. @Input() ariaLabelledby: string; @@ -391,6 +401,9 @@ implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges { if (!this.selectedId && !this.selected){ this.selected = this.entrylist.find(e => e.id == null); } + + document.querySelectorAll('ngb-typeahead-window button[disabled]').forEach(b => b.setAttribute('tabindex', '-1')); + this.elm.nativeElement.querySelector('input').addEventListener('keydown', this.onKeydown.bind(this)); } ngOnChanges(changes: SimpleChanges) { @@ -457,6 +470,31 @@ implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges { } } + onKeydown($event: KeyboardEvent) { + //console.debug('Key: ', $event); + + if (this.instance.isPopupOpen()) { + return; + } + + if ( $event.key == 'ArrowDown' && $event.ctrlKey && $event.shiftKey ) { + setTimeout(() => this.openMe($event)); + return; + } + + // a shortcut if Enter is the only key event you're interested in + if ( $event.key == 'Enter' ) { + this.onEnter(); + } + + // Pass through to calling component via (comboboxKeydown)="yourFunction($event)" + this.comboboxKeydown.emit($event); + } + + onEnter() { + this.comboboxEnter.emit(this.selected.id); + } + openMe($event) { // Give the input a chance to focus then fire the click // handler to force open the typeahead diff --git a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html index 4224d97c7f..b507163ac9 100644 --- a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html +++ b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.html @@ -14,6 +14,8 @@ id="{{domId}}" [name]="name" [attr.aria-label]="ariaLabel" + [attr.aria-describedby]="ariaDescribedby" + [attr.ngbAutofocus]="ngbAutofocus" [placeholder]="placeholder" [disabled]="disabled" [required]="required" @@ -21,6 +23,7 @@ [ngbTypeahead]="filter" [resultTemplate]="displayTemplate" [inputFormatter]="formatter" + (orgSelectEnter)="onEnter()" (blur)="handleBlur()" (click)="click$.next($event.target.value)" (selectItem)="orgChanged($event)" diff --git a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts index 59e099d9bd..b9d58bb2a8 100644 --- a/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts +++ b/Open-ILS/src/eg2/src/app/share/org-select/org-select.component.ts @@ -1,5 +1,5 @@ /** TODO PORT ME TO <eg-combobox> */ -import {Component, OnInit, Input, Output, ViewChild, EventEmitter} from '@angular/core'; +import {Component, OnInit, Input, Output, ViewChild, EventEmitter, ElementRef, AfterViewInit} from '@angular/core'; import {Observable, Subject} from 'rxjs'; import {map, mapTo, debounceTime, distinctUntilChanged, merge, filter} from 'rxjs/operators'; import {AuthService} from '@eg/core/auth.service'; @@ -38,7 +38,7 @@ interface OrgDisplay { selector: 'eg-org-select', templateUrl: './org-select.component.html' }) -export class OrgSelectComponent implements OnInit { +export class OrgSelectComponent implements OnInit, AfterViewInit { static _domId = 0; showCombinedNames = false; // Managed via user/workstation setting @@ -76,6 +76,9 @@ export class OrgSelectComponent implements OnInit { // ARIA label for selector. Required if there is no <label> in the markup. @Input() ariaLabel?: string; + // ARIA describedby, for attaching error messages + @Input() ariaDescribedby?: string = null; + // ID to display in the DOM for this selector @Input() domId = 'eg-org-select-' + OrgSelectComponent._domId++; @@ -97,6 +100,8 @@ export class OrgSelectComponent implements OnInit { @Input() required = false; + @Input() ngbAutofocus = null; // passthrough for [ngbAutofocus] + // List of org unit IDs to exclude from the selector hidden: number[] = []; @Input() set hideOrgs(ids: number[]) { @@ -154,6 +159,14 @@ export class OrgSelectComponent implements OnInit { // in the selector. @Input() orgClassCallback: (orgId: number) => string; + // Emitted when the Enter key is pressed in the input and the popup is not open + @Output() orgSelectEnter = new EventEmitter<number>(); + + // Emitted when a key is pressed in the input and the popup is not open. + // A passthrough for keyboard events on the input. + // Example: (orgSelectKey)="$event.key === 'Escape' ? cancel() : handleKeydown($event)" + @Output() orgSelectKey = new EventEmitter<Event>(); + // Emitted when the org unit value is changed via the selector. // Does not fire on initialOrg @Output() onChange = new EventEmitter<IdlObject>(); @@ -185,7 +198,8 @@ export class OrgSelectComponent implements OnInit { private store: StoreService, private serverStore: ServerStoreService, private org: OrgService, - private perm: PermService + private perm: PermService, + private elm: ElementRef, ) { this.orgClassCallback = (orgId: number): string => ''; } @@ -251,6 +265,10 @@ export class OrgSelectComponent implements OnInit { }); } + ngAfterViewInit(): void { + this.elm.nativeElement.querySelector('input').addEventListener('keydown', this.onKeydown.bind(this)); + } + getDisplayLabel(org: IdlObject): string { if (this.showCombinedNames) { return `${org.name()} (${org.shortname()})`; @@ -358,6 +376,38 @@ export class OrgSelectComponent implements OnInit { this.selected = null; } + onKeydown($event: KeyboardEvent) { + // console.debug('Key: ', $event); + + if (this.instance.isPopupOpen()) { + return; + } + + if ( $event.key === 'ArrowDown' && $event.ctrlKey && $event.shiftKey ) { + setTimeout(() => this.openMe($event)); + return; + } + + // a shortcut to the Org ID if Enter is the only key event you're interested in + if ( $event.key === 'Enter' ) { + this.onEnter(); + } + + // Pass through to calling component via (orgSelectKey) + this.orgSelectKey.emit($event); + } + + onEnter() { + this.orgSelectEnter.emit(this.selected.id); + } + + openMe($event) { + // Give the input a chance to focus then fire the click + // handler to force open the typeahead + document.getElementById(this.domId).focus(); + setTimeout(() => this.click$.next('')); + } + // NgbTypeahead doesn't offer a way to style the dropdown // button directly, so we have to reach up and style it ourselves. applyDisableStyle() { commit 61db07fc1d5f08396a442e6be9e4f5a08bc41ef3 Author: Jason Etheridge <jason@equinoxOLI.org> Date: Wed Mar 12 17:38:10 2025 +0000 LP2074112 IANT Broadcast service unique IDs Part of LP2074112 Item Alerts, Notes, Tags, and Templates Rework. Adds a unique ID to the broadcast service instances to distinguish broadcasts from different sources. Signed-off-by: Jason Etheridge <jason@equinoxOLI.org> Signed-off-by: Stephanie Leary <stephanie.leary@equinoxoli.org> Signed-off-by: Terran McCanna <tmccanna@georgialibraries.org> diff --git a/Open-ILS/src/eg2/src/app/core/server-store.service.ts b/Open-ILS/src/eg2/src/app/core/server-store.service.ts index 7946e9e1a6..19875fca8a 100644 --- a/Open-ILS/src/eg2/src/app/core/server-store.service.ts +++ b/Open-ILS/src/eg2/src/app/core/server-store.service.ts @@ -1,11 +1,13 @@ /** * Set and get server-stored settings. */ -import {Injectable} from '@angular/core'; -import {tap} from 'rxjs/operators'; +import {Injectable,OnDestroy} from '@angular/core'; +import {Subject} from 'rxjs'; +import {tap, takeUntil} from 'rxjs/operators'; import {AuthService} from './auth.service'; import {NetService} from './net.service'; import {DbStoreService} from './db-store.service'; +import {BroadcastService} from '@eg/share/util/broadcast.service'; // Settings summary objects returned by the API interface ServerSettingSummary { @@ -17,15 +19,37 @@ interface ServerSettingSummary { } @Injectable({providedIn: 'root'}) -export class ServerStoreService { +export class ServerStoreService implements OnDestroy { cache: {[key: string]: any}; + private destroy$ = new Subject<void>(); + private cacheCleared = new Subject<void>(); + cacheCleared$ = this.cacheCleared.asObservable(); + constructor( private db: DbStoreService, private net: NetService, - private auth: AuthService) { + private auth: AuthService, + private broadcaster: BroadcastService) { this.cache = {}; + // Listen for cache invalidation broadcasts + this.broadcaster.listen('eg.invalidate_server_store_cache') + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.cache = {}; + this.cacheCleared.next(); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + invalidateAllCaches() { + console.debug('ServerStoreService: invalidateAllCaches()'); + this.broadcaster.broadcast('eg.invalidate_server_store_cache', { updated: true }); } setItem(key: string, value: any): Promise<any> { @@ -163,7 +187,7 @@ export class ServerStoreService { table: 'Setting', action: 'insertOrReplace', rows: rows - }).then(_ => values).catch(_ => values); + }).then(_ => { this.invalidateAllCaches(); return values;}).catch(_ => values); } getSettingsFromDb(names: string[]): Promise<{[key: string]: any}> { diff --git a/Open-ILS/src/eg2/src/app/share/util/broadcast.service.ts b/Open-ILS/src/eg2/src/app/share/util/broadcast.service.ts index 76c1292917..85552fcb5a 100644 --- a/Open-ILS/src/eg2/src/app/share/util/broadcast.service.ts +++ b/Open-ILS/src/eg2/src/app/share/util/broadcast.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-len */ /** * Create and consume BroadcastChannel broadcasts */ @@ -8,14 +9,19 @@ interface BroadcastSub { emitter: EventEmitter<any>; } -@Injectable() +@Injectable({ + providedIn: 'root' +}) export class BroadcastService { + private instanceId = crypto.randomUUID(); + subscriptions: {[key: string]: BroadcastSub} = {}; noOpEmitter = new EventEmitter<any>(); listen(key: string): EventEmitter<any> { + console.debug('BroadcastService('+this.instanceId+'), listen: key', key); if (typeof BroadcastChannel === 'undefined') { return this.noOpEmitter; } @@ -28,7 +34,7 @@ export class BroadcastService { const channel = new BroadcastChannel(key); channel.onmessage = (e) => { - console.debug('Broadcast received', e.data); + console.debug('BroadcastService('+this.instanceId+'), Broadcast received: key, data', key, e.data); emitter.emit(e.data); }; @@ -41,6 +47,7 @@ export class BroadcastService { } broadcast(key: string, value: any) { + console.debug('BroadcastService(' + this.instanceId + '), broadcast: key, value', key, value); if (typeof BroadcastChannel === 'undefined') { return; } if (this.subscriptions[key]) { @@ -56,6 +63,7 @@ export class BroadcastService { } close(key: string) { + console.debug('BroadcastService(' + this.instanceId + '), close: key', key); if (typeof BroadcastChannel === 'undefined') { return; } if (this.subscriptions[key]) { @@ -64,5 +72,38 @@ export class BroadcastService { delete this.subscriptions[key]; } } + + listenIgnoreSameSource(key: string): EventEmitter<any> { + if (typeof BroadcastChannel === 'undefined') { + return this.noOpEmitter; + } + + const emitter = new EventEmitter<any>(); + + this.listen(key) + .subscribe(data => { + // Only emit if from different source + if (data?.sourceId !== this.instanceId) { + console.debug('BroadcastService(' + this.instanceId + '), Broadcast received from different source: key, data', key, data); + emitter.emit(data); + } else { + console.debug('BroadcastService(' + this.instanceId + '), Broadcast received and ignored same source message: key, data', key, data); + } + }); + + return emitter; + } + + broadcastWithSource(key: string, value: any) { + if (typeof BroadcastChannel === 'undefined') { return; } + + const valueWithSource = { + ...value, + sourceId: this.instanceId + }; + + console.debug('broadcastWithSource(' + this.instanceId + '): key, value', key, valueWithSource); + this.broadcast(key, valueWithSource); + } } diff --git a/Open-ILS/src/eg2/src/app/staff/admin/acq/currency/currencies.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/acq/currency/currencies.component.ts index db1b2668ea..26c545a7c7 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/acq/currency/currencies.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/admin/acq/currency/currencies.component.ts @@ -12,6 +12,7 @@ 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 {BroadcastService} from '@eg/share/util/broadcast.service'; import {NetService} from '@eg/core/net.service'; import {ExchangeRatesDialogComponent} from './exchange-rates-dialog.component'; import {forkJoin} from 'rxjs'; @@ -44,9 +45,10 @@ export class CurrenciesComponent extends AdminPageComponent implements OnInit { pcrud: PcrudService, perm: PermService, toast: ToastService, - private net: NetService + private net: NetService, + broadcaster: BroadcastService ) { - super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast); + super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast, broadcaster); this.dataSource = new GridDataSource(); } diff --git a/Open-ILS/src/eg2/src/app/staff/admin/acq/distribution_formula/distribution-formulas.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/acq/distribution_formula/distribution-formulas.component.ts index 09f8f55f95..5393997cf8 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/acq/distribution_formula/distribution-formulas.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/admin/acq/distribution_formula/distribution-formulas.component.ts @@ -12,6 +12,7 @@ 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 {BroadcastService} from '@eg/share/util/broadcast.service'; import {NetService} from '@eg/core/net.service'; import {mergeMap} from 'rxjs/operators'; import {StringComponent} from '@eg/share/string/string.component'; @@ -46,9 +47,10 @@ export class DistributionFormulasComponent extends AdminPageComponent implements pcrud: PcrudService, perm: PermService, toast: ToastService, - private net: NetService + private net: NetService, + broadcaster: BroadcastService ) { - super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast); + super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast, broadcaster); this.dataSource = new GridDataSource(); } diff --git a/Open-ILS/src/eg2/src/app/staff/admin/acq/edi_attr_set/edi-attr-sets.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/acq/edi_attr_set/edi-attr-sets.component.ts index 5d578de8f1..5753bca481 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/acq/edi_attr_set/edi-attr-sets.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/admin/acq/edi_attr_set/edi-attr-sets.component.ts @@ -12,6 +12,7 @@ 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 {BroadcastService} from '@eg/share/util/broadcast.service'; import {NetService} from '@eg/core/net.service'; import {Observable, of} from 'rxjs'; import {mergeMap} from 'rxjs/operators'; @@ -48,9 +49,10 @@ export class EdiAttrSetsComponent extends AdminPageComponent implements OnInit { pcrud: PcrudService, perm: PermService, toast: ToastService, - private net: NetService + private net: NetService, + broadcaster: BroadcastService ) { - super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast); + super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast, broadcaster); this.dataSource = new GridDataSource(); } diff --git a/Open-ILS/src/eg2/src/app/staff/admin/acq/funds/funding-sources.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/acq/funds/funding-sources.component.ts index a604724f67..0c0372601e 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/acq/funds/funding-sources.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/admin/acq/funds/funding-sources.component.ts @@ -13,6 +13,7 @@ import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component'; import {OrgService} from '@eg/core/org.service'; import {PermService} from '@eg/core/perm.service'; import {AuthService} from '@eg/core/auth.service'; +import {BroadcastService} from '@eg/share/util/broadcast.service'; import {NetService} from '@eg/core/net.service'; import {mergeMap} from 'rxjs/operators'; import {Observable, forkJoin, of} from 'rxjs'; @@ -53,9 +54,10 @@ export class FundingSourcesComponent extends AdminPageComponent implements OnIni pcrud: PcrudService, perm: PermService, toast: ToastService, - private net: NetService + private net: NetService, + broadcaster: BroadcastService ) { - super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast); + super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast, broadcaster); this.dataSource = new GridDataSource(); } diff --git a/Open-ILS/src/eg2/src/app/staff/admin/acq/funds/funds-manager.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/acq/funds/funds-manager.component.ts index 400a0cf248..a0f3d53bb2 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/acq/funds/funds-manager.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/admin/acq/funds/funds-manager.component.ts @@ -12,6 +12,7 @@ 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 {BroadcastService} from '@eg/share/util/broadcast.service'; import {NetService} from '@eg/core/net.service'; import {StringComponent} from '@eg/share/string/string.component'; import {FundDetailsDialogComponent} from './fund-details-dialog.component'; @@ -47,9 +48,10 @@ export class FundsManagerComponent extends AdminPageComponent implements OnInit, private perm2: PermService, // need copy because perm is private to base // component toast: ToastService, - private net: NetService + private net: NetService, + broadcaster: BroadcastService ) { - super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast); + super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast, broadcaster); this.dataSource = new GridDataSource(); } diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/admin-carousel.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/local/admin-carousel.component.ts index a6c28b65da..b4caa44c2d 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/local/admin-carousel.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/admin/local/admin-carousel.component.ts @@ -9,6 +9,7 @@ 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 {BroadcastService} from '@eg/share/util/broadcast.service'; import {NetService} from '@eg/core/net.service'; import {GridCellTextGenerator} from '@eg/share/grid/grid'; import {StringComponent} from '@eg/share/string/string.component'; @@ -44,9 +45,10 @@ export class AdminCarouselComponent extends AdminPageComponent implements OnInit pcrud: PcrudService, perm: PermService, toast: ToastService, - private net: NetService + private net: NetService, + broadcaster: BroadcastService ) { - super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast); + super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast, broadcaster); } ngOnInit() { diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/search-filter/search-filter-group.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/local/search-filter/search-filter-group.component.ts index d68bbade2f..cf96d1e6e7 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/local/search-filter/search-filter-group.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/admin/local/search-filter/search-filter-group.component.ts @@ -11,6 +11,7 @@ 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 {BroadcastService} from '@eg/share/util/broadcast.service'; import {StringComponent} from '@eg/share/string/string.component'; import {AdminPageComponent} from '../../../share/admin-page/admin-page.component'; @@ -37,9 +38,10 @@ export class SearchFilterGroupComponent extends AdminPageComponent implements On pcrud: PcrudService, perm: PermService, toast: ToastService, - private router: Router + private router: Router, + broadcaster: BroadcastService ) { - super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast); + super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast, broadcaster); } ngOnInit() { diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/staff-portal-page.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/staff-portal-page.component.ts index efaf1f98e1..f0dd16c197 100644 --- a/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/staff-portal-page.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/admin/local/staff_portal_page/staff-portal-page.component.ts @@ -9,6 +9,7 @@ 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 {BroadcastService} from '@eg/share/util/broadcast.service'; import {NetService} from '@eg/core/net.service'; import {GridCellTextGenerator} from '@eg/share/grid/grid'; import {StringComponent} from '@eg/share/string/string.component'; @@ -48,9 +49,10 @@ export class AdminStaffPortalPageComponent extends AdminPageComponent implements pcrud: PcrudService, perm: PermService, toast: ToastService, - private net: NetService + private net: NetService, + broadcaster: BroadcastService ) { - super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast); + super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast, broadcaster); } ngOnInit() { 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 index 65c4c6be06..4bc4d799a4 100644 --- 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 @@ -11,6 +11,7 @@ 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 {BroadcastService} from '@eg/share/util/broadcast.service'; import {AdminPageComponent} from '../../../share/admin-page/admin-page.component'; import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component'; @@ -37,9 +38,10 @@ export class FloatingGroupComponent extends AdminPageComponent implements OnInit pcrud: PcrudService, perm: PermService, toast: ToastService, - private router: Router + private router: Router, + broadcaster: BroadcastService ) { - super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast); + super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast, broadcaster); } ngOnInit() { diff --git a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts index b83c6e2a5c..6940e841b9 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts @@ -15,6 +15,7 @@ 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 {BroadcastService} from '@eg/share/util/broadcast.service'; import {FmRecordEditorComponent, FmFieldOptions } from '@eg/share/fm-editor/fm-editor.component'; import {StringComponent} from '@eg/share/string/string.component'; @@ -199,7 +200,8 @@ export class AdminPageComponent implements OnInit { public auth: AuthService, public pcrud: PcrudService, private perm: PermService, - public toast: ToastService + public toast: ToastService, + public broadcaster: BroadcastService ) { this.translatableFields = []; this.configFields = []; @@ -426,6 +428,7 @@ export class AdminPageComponent implements OnInit { this.successString.current() .then(str => this.toast.success(str)); this.grid.reload(); + this.broadcaster.broadcast(`eg.${this.idlEditClass || this.idlClass}_updated`, { action: 'edit', result: result }); resolve(result); }, (error: unknown) => { @@ -444,7 +447,8 @@ export class AdminPageComponent implements OnInit { if (!thing) { return; } this.showEditDialog(thing).then( - () => editOneThing(idlThings.shift())); + () => editOneThing(idlThings.shift()) + ); }; editOneThing(idlThings.shift()); @@ -473,6 +477,7 @@ export class AdminPageComponent implements OnInit { val => { this.undeleteSuccessString.current() .then(str => this.toast.success(str)); + this.broadcaster.broadcast(`eg.${this.idlEditClass || this.idlClass}_updated`, { action: 'undelete', result: val }); }, (err: unknown) => { this.undeleteFailedString.current() @@ -499,6 +504,7 @@ export class AdminPageComponent implements OnInit { val => { this.deleteSuccessString.current() .then(str => this.toast.success(str)); + this.broadcaster.broadcast(`eg.${this.idlEditClass || this.idlClass}_updated`, { action: 'delete', result: val }); }, (err: unknown) => { this.deleteFailedString.current() @@ -549,6 +555,7 @@ export class AdminPageComponent implements OnInit { this.createString.current() .then(str => this.toast.success(str)); this.grid.reload(); + this.broadcaster.broadcast(`eg.${this.idlEditClass || this.idlClass}_updated`, { action: 'create', result: ok }); }, (rejection: any) => { if (!rejection.dismissed) { commit 500ca5f441c04b91e791eeccc6e16d3a1836ab54 Author: Stephanie Leary <stephanie.leary@equinoxoli.org> Date: Wed Mar 12 16:51:33 2025 +0000 LP2074112 IANT admin page return button in new tabs Part of LP2074112 Item Alerts, Notes, Tags, and Templates Rework. Hides the 'Return' button on generic admin grid screens when the page has been opened in a new tab and there is no history to return to. Signed-off-by: Stephanie Leary <stephanie.leary@equinoxoli.org> Signed-off-by: Jason Etheridge <jason@equinoxOLI.org> Signed-off-by: Terran McCanna <tmccanna@georgialibraries.org> diff --git a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html index b982d2e15f..ff41223445 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.html @@ -46,7 +46,7 @@ </eg-org-family-select> </ng-container> </div> - <div class="col-lg-6 d-flex"> + <div class="col-lg-6 hstack"> <div class="flex-1"></div><!-- push right --> <ng-container *ngIf="gridFilters"> <span i18n>Filters Applied: {{gridFilters | json}}</span> @@ -54,7 +54,7 @@ [attr.href]="clearGridFiltersUrl()" i18n>Clear Filters</a> <button class="btn btn-info label-with-material-icon" type="button" - (click)="goBack()" [disabled]="hasNoHistory()"> + (click)="goBack()" [hidden]="hasNoHistory()"> <span class="material-icons" aria-hidden="true">keyboard_backspace</span> <span i18n>Return</span> </button> diff --git a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts index 69a9d3955e..b83c6e2a5c 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/admin-page/admin-page.component.ts @@ -689,7 +689,7 @@ export class AdminPageComponent implements OnInit { } hasNoHistory(): boolean { - return history.length === 0; + return history.length <= 1; } goBack() { commit a9ff9be260c2f428ced83088345007dc0b000ad9 Author: Jason Etheridge <jason@equinoxOLI.org> Date: Wed Mar 12 18:22:08 2025 +0000 LP2074112 IANT Database upgrade and seed data Part of LP2074112 Item Alerts, Notes, Tags, and Templates Rework. Signed-off-by: Jason Etheridge <jason@equinoxOLI.org> Signed-off-by: Stephanie Leary <stephanie.leary@equinoxoli.org> Signed-off-by: Terran McCanna <tmccanna@georgialibraries.org> diff --git a/Open-ILS/src/sql/Pg/950.data.seed-values.sql b/Open-ILS/src/sql/Pg/950.data.seed-values.sql index 65c51061ab..21bb466964 100644 --- a/Open-ILS/src/sql/Pg/950.data.seed-values.sql +++ b/Open-ILS/src/sql/Pg/950.data.seed-values.sql @@ -24886,6 +24886,126 @@ VALUES ( 'bool' ); +-- upgrade/XXXX.data.org-setting-template-bar.sql + +INSERT INTO config.org_unit_setting_type + (grp, name, datatype, label, description) +VALUES ( + 'gui', + 'ui.cat.volume_copy_editor.template_bar.show_save_template', 'bool', + oils_i18n_gettext( + 'ui.cat.volume_copy_editor.template_bar.show_save_template', + 'Show "Save Template" in Holdings Editor', + 'coust', + 'label' + ), + oils_i18n_gettext( + 'ui.cat.volume_copy_editor.template_bar.show_save_template', + 'Displays the "Save Template" button for the template bar in the Volume/Copy/Holdings Editor. By default, this is only displayed when working with templates from the Admin interface.', + 'coust', + 'description' + ) +); + +INSERT INTO config.org_unit_setting_type + (grp, name, datatype, label, description) +VALUES ( + 'gui', + 'ui.cat.volume_copy_editor.hide_template_bar', 'bool', + oils_i18n_gettext( + 'ui.cat.volume_copy_editor.hide_template_bar', + 'Hide the entire template bar in Holdings Editor', + 'coust', + 'label' + ), + oils_i18n_gettext( + 'ui.cat.volume_copy_editor.hide_template_bar', + 'Hides the template bar in the Volume/Copy/Holdings Editor. By default, the template bar is displayed in this interface.', + 'coust', + 'description' + ) +); + +INSERT INTO config.workstation_setting_type (name, grp, datatype, label) +VALUES ( + 'eg.grid.cat.volcopy.template_grid', 'gui', 'object', + oils_i18n_gettext( + 'eg.grid.cat.volcopy.template_grid', + 'Holdings Template Grid Settings', + 'cwst', 'label' + ) +); + +INSERT INTO config.workstation_setting_type (name, grp, datatype, label) +VALUES ( + 'eg.grid.holdings.copy_tags.tag_map_list', 'gui', 'object', + oils_i18n_gettext( + 'eg.grid.holdings.copy_tags.tag_map_list', + 'Copy Tag Maps Template Grid Settings', + 'cwst', 'label' + ) +); + +-- upgrade/XXXX.data.org-setting-template-bar.sql + +INSERT INTO config.org_unit_setting_type + (grp, name, datatype, label, description) +VALUES ( + 'gui', + 'ui.cat.volume_copy_editor.template_bar.show_save_template', 'bool', + oils_i18n_gettext( + 'ui.cat.volume_copy_editor.template_bar.show_save_template', + 'Show "Save Template" in Holdings Editor', + 'coust', + 'label' + ), + oils_i18n_gettext( + 'ui.cat.volume_copy_editor.template_bar.show_save_template', + 'Displays the "Save Template" button for the template bar in the Volume/Copy/Holdings Editor. By default, this is only displayed when working with templates from the Admin interface.', + 'coust', + 'description' + ) +); + +INSERT INTO config.org_unit_setting_type + (grp, name, datatype, label, description) +VALUES ( + 'gui', + 'ui.cat.volume_copy_editor.hide_template_bar', 'bool', + oils_i18n_gettext( + 'ui.cat.volume_copy_editor.hide_template_bar', + 'Hide the entire template bar in Holdings Editor', + 'coust', + 'label' + ), + oils_i18n_gettext( + 'ui.cat.volume_copy_editor.hide_template_bar', + 'Hides the template bar in the Volume/Copy/Holdings Editor. By default, the template bar is displayed in this interface.', + 'coust', + 'description' + ) +); + +INSERT INTO config.workstation_setting_type (name, grp, datatype, label) +VALUES ( + 'eg.grid.cat.volcopy.template_grid', 'gui', 'object', + oils_i18n_gettext( + 'eg.grid.cat.volcopy.template_grid', + 'Holdings Template Grid Settings', + 'cwst', 'label' + ) +); + +INSERT INTO config.workstation_setting_type (name, grp, datatype, label) +VALUES ( + 'eg.grid.holdings.copy_tags.tag_map_list', 'gui', 'object', + oils_i18n_gettext( + 'eg.grid.holdings.copy_tags.tag_map_list', + 'Copy Tag Maps Template Grid Settings', + 'cwst', 'label' + ) +); + INSERT into config.workstation_setting_type (name, grp, label, description, datatype) VALUES ( diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.org-setting-template-bar.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.org-setting-template-bar.sql new file mode 100644 index 0000000000..2fb48a6492 --- /dev/null +++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.org-setting-template-bar.sql @@ -0,0 +1,63 @@ +BEGIN; + +SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version); + +INSERT INTO config.org_unit_setting_type + (grp, name, datatype, label, description) +VALUES ( + 'gui', + 'ui.cat.volume_copy_editor.template_bar.show_save_template', 'bool', + oils_i18n_gettext( + 'ui.cat.volume_copy_editor.template_bar.show_save_template', + 'Show "Save Template" in Holdings Editor', + 'coust', + 'label' + ), + oils_i18n_gettext( + 'ui.cat.volume_copy_editor.template_bar.show_save_template', + 'Displays the "Save Template" button for the template bar in the Volume/Copy/Holdings Editor. By default, this is only displayed when working with templates from the Admin interface.', + 'coust', + 'description' + ) +); + +INSERT INTO config.org_unit_setting_type + (grp, name, datatype, label, description) +VALUES ( + 'gui', + 'ui.cat.volume_copy_editor.hide_template_bar', 'bool', + oils_i18n_gettext( + 'ui.cat.volume_copy_editor.hide_template_bar', + 'Hide the entire template bar in Holdings Editor', + 'coust', + 'label' + ), + oils_i18n_gettext( + 'ui.cat.volume_copy_editor.hide_template_bar', + 'Hides the template bar in the Volume/Copy/Holdings Editor. By default, the template bar is displayed in this interface.', + 'coust', + 'description' + ) +); + +INSERT INTO config.workstation_setting_type (name, grp, datatype, label) +VALUES ( + 'eg.grid.cat.volcopy.template_grid', 'gui', 'object', + oils_i18n_gettext( + 'eg.grid.cat.volcopy.template_grid', + 'Holdings Template Grid Settings', + 'cwst', 'label' + ) +); + +INSERT INTO config.workstation_setting_type (name, grp, datatype, label) +VALUES ( + 'eg.grid.holdings.copy_tags.tag_map_list', 'gui', 'object', + oils_i18n_gettext( + 'eg.grid.holdings.copy_tags.tag_map_list', + 'Copy Tag Maps Template Grid Settings', + 'cwst', 'label' + ) +); + +COMMIT; commit 85989ff210441c0dff47ccf572586e681e0f44ce Author: Jason Etheridge <jason@equinoxOLI.org> Date: Wed Mar 12 18:21:40 2025 +0000 LP2074112 IANT IDL changes Part of LP2074112 Item Alerts, Notes, Tags, and Templates Rework. Signed-off-by: Jason Etheridge <jason@equinoxOLI.org> Signed-off-by: Stephanie Leary <stephanie.leary@equinoxoli.org> Signed-off-by: Terran McCanna <tmccanna@georgialibraries.org> diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml index 3acff5628a..c1ed452927 100644 --- a/Open-ILS/examples/fm_IDL.xml +++ b/Open-ILS/examples/fm_IDL.xml @@ -14069,7 +14069,7 @@ SELECT usr, <class id="cctt" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="config::copy_tag_type" oils_persist:tablename="config.copy_tag_type" reporter:label="Item Tag Types" oils_persist:field_safe="true" oils_persist:cardinality="low"> <fields oils_persist:primary="code"> <field reporter:label="Code" name="code" reporter:selector="label" reporter:datatype="id" oils_obj:required="true"/> - <field reporter:label="Label" name="label" reporter:datatype="text" oils_obj:required="true"/> + <field reporter:label="Tag Type" name="label" reporter:datatype="text" oils_obj:required="true"/> <field reporter:label="Owner" name="owner" reporter:datatype="org_unit" oils_obj:required="true"/> </fields> <links> @@ -14087,9 +14087,9 @@ SELECT usr, <class id="acpt" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="asset::copy_tag" oils_persist:tablename="asset.copy_tag" reporter:label="Item Tags" oils_persist:field_safe="true"> <fields oils_persist:primary="id" oils_persist:sequence="asset.copy_tag_id_seq"> <field reporter:label="ID" name="id" reporter:datatype="id"/> - <field reporter:label="Item Tag Type" name="tag_type" reporter:datatype="link"/> - <field reporter:label="Label" name="label" reporter:datatype="text" oils_obj:required="true"/> - <field reporter:label="Value" name="value" reporter:datatype="text" oils_obj:required="true"/> + <field reporter:label="Tag Type" name="tag_type" reporter:datatype="link"/> + <field reporter:label="Tag Label" name="label" reporter:datatype="text" oils_obj:required="true"/> + <field reporter:label="Tag Value" name="value" reporter:datatype="text" oils_obj:required="true"/> <field reporter:label="Staff Note" name="staff_note" reporter:datatype="text"/> <field reporter:label="Is OPAC Visible?" name="pub" reporter:datatype="bool"/> <field reporter:label="Owner" name="owner" reporter:datatype="org_unit" oils_obj:required="true"/> diff --git a/Open-ILS/src/eg2/src/app/core/idl.service.ts b/Open-ILS/src/eg2/src/app/core/idl.service.ts index e172061714..87198d3729 100644 --- a/Open-ILS/src/eg2/src/app/core/idl.service.ts +++ b/Open-ILS/src/eg2/src/app/core/idl.service.ts @@ -232,6 +232,61 @@ export class IdlService { return hash; } + fromHash(hash: any, baseClass: string, convertBooleans = false): any { + // Handle primitives + if (typeof hash !== 'object' || hash === null || hash._isfieldmapper) { + return hash; + } + + // Handle arrays + if (Array.isArray(hash)) { + return hash.map(item => this.fromHash(item, baseClass, convertBooleans)); + } + + if (!baseClass || !this.classes[baseClass]) { + throw new Error(`Invalid or missing base class: ${baseClass}`); + } + + // Create and populate IDL object + const obj = this.create(baseClass); + const fieldMap = this.classes[baseClass].field_map; + + Object.entries(hash).forEach(([key, value]) => { + // Handle flattened nested objects (key contains '.') + if (key.includes('.')) { + const [field, subfield] = key.split('.'); + if (!obj[field]()) { + // Initialize nested object if needed + const linkedClass = fieldMap[field]?.class; + if (linkedClass) { + obj[field](this.create(linkedClass)); + } + } + if (obj[field]()) { + const fieldValue = convertBooleans && fieldMap[subfield]?.datatype === 'bool' + ? this.toBoolean(value) + : value; + obj[field]()[subfield](fieldValue); + } + } else { + // Handle regular fields and linked objects + const fieldDef = fieldMap[key]; + if (fieldDef?.class) { + // This is a linked field, recursively create the linked object + obj[key](this.fromHash(value, fieldDef.class, convertBooleans)); + } else if (typeof obj[key] === 'function') { + // Regular field + const fieldValue = convertBooleans && fieldDef?.datatype === 'bool' + ? this.toBoolean(value) + : value; + obj[key](fieldValue); + } + } + }); + + return obj; + } + // Returns true if both objects have the same IDL class and pkey value. pkeyMatches(obj1: IdlObject, obj2: IdlObject) { if (!obj1 || !obj2) { return false; } diff --git a/Open-ILS/src/eg2/src/app/core/idl.spec.ts b/Open-ILS/src/eg2/src/app/core/idl.spec.ts index a97108d305..eed84948d2 100644 --- a/Open-ILS/src/eg2/src/app/core/idl.spec.ts +++ b/Open-ILS/src/eg2/src/app/core/idl.spec.ts @@ -100,5 +100,128 @@ describe('IdlService', () => { expect(service.sortIdlFields(idlFields, fieldNames)).toEqual(expectedOrder); }); + it('should recreate an IDL object from a hash', () => { + service.parseIdl(); + const hash = { + id: 123, + name: 'AN ORG', + active: true + }; + const org = service.fromHash(hash, 'aou'); + expect(org._isfieldmapper).toBe(true); + expect(org.classname).toBe('aou'); + expect(org.id()).toBe(123); + expect(org.name()).toBe('AN ORG'); + expect(org.active()).toBe(true); + }); + + it('should maintain data integrity through roundtrip conversion', () => { + service.parseIdl(); + // Create an original IDL object with nested structure + const original = service.create('aou'); + original.id(123); + original.name('Test Org'); + original.active(true); + + const parent = service.create('aou'); + parent.id(456); + parent.name('Parent Org'); + parent.active(false); + original.parent(parent); + + // Convert to hash and back + const hash = service.toHash(original); + const roundtripped = service.fromHash(hash, 'aou', true); + + // Verify all properties maintained their values + expect(roundtripped.id()).toBe(original.id()); + expect(roundtripped.name()).toBe(original.name()); + expect(roundtripped.active()).toBe(original.active()); + expect(roundtripped.parent().id()).toBe(original.parent().id()); + expect(roundtripped.parent().name()).toBe(original.parent().name()); + expect(roundtripped.parent().active()).toBe(original.parent().active()); + + // Verify the objects have the same structure + expect(roundtripped._isfieldmapper).toBe(true); + expect(roundtripped.classname).toBe(original.classname); + expect(roundtripped.parent()._isfieldmapper).toBe(true); + expect(roundtripped.parent().classname).toBe(original.parent().classname); + }); + + it('should handle boolean conversion when enabled', () => { + service.parseIdl(); + const hash = { + id: 123, + name: 'AN ORG', + active: 't', // PostgreSQL-style boolean + 'parent.active': 'f' + }; + const org = service.fromHash(hash, 'aou', true); + expect(org.active()).toBe(true); + expect(org.parent().active()).toBe(false); + }); + + it('should handle nested IDL objects', () => { + service.parseIdl(); + const hash = { + id: 456, + name: 'Child Org', + parent: { + id: 123, + name: 'Parent Org' + } + }; + const org = service.fromHash(hash, 'aou'); + expect(org.parent()._isfieldmapper).toBe(true); + expect(org.parent().classname).toBe('aou'); + expect(org.parent().id()).toBe(123); + expect(org.parent().name()).toBe('Parent Org'); + }); + + it('should handle flattened object notation', () => { + service.parseIdl(); + const hash = { + id: 456, + name: 'Child Org', + 'parent.id': 123, + 'parent.name': 'Parent Org' + }; + const org = service.fromHash(hash, 'aou'); + expect(org.parent()._isfieldmapper).toBe(true); + expect(org.parent().classname).toBe('aou'); + expect(org.parent().id()).toBe(123); + expect(org.parent().name()).toBe('Parent Org'); + }); + + it('should handle arrays of IDL objects', () => { + service.parseIdl(); + const hash = [{ + id: 1, + name: 'First Org' + }, { + id: 2, + name: 'Second Org' + }]; + const orgs = service.fromHash(hash, 'aou'); + expect(Array.isArray(orgs)).toBe(true); + expect(orgs[0]._isfieldmapper).toBe(true); + expect(orgs[0].name()).toBe('First Org'); + expect(orgs[1].name()).toBe('Second Org'); + }); + + it('should throw error for invalid base class', () => { + service.parseIdl(); + const hash = { id: 123 }; + expect(() => service.fromHash(hash, 'not_a_class')) + .toThrow('Invalid or missing base class: not_a_class'); + }); + + it('should preserve primitive values', () => { + service.parseIdl(); + expect(service.fromHash(123, 'aou')).toBe(123); + expect(service.fromHash(null, 'aou')).toBe(null); + expect(service.fromHash(undefined, 'aou')).toBe(undefined); + }); + }); commit 8cd780cb7c0ec55705ddc7e099e77143de9c4d52 Author: Jason Etheridge <jason@equinoxOLI.org> Date: Wed Mar 12 18:20:56 2025 +0000 LP2074112 IANT Perl changes Part of LP2074112 Item Alerts, Notes, Tags, and Templates Rework. Signed-off-by: Jason Etheridge <jason@equinoxOLI.org> Signed-off-by: Stephanie Leary <stephanie.leary@equinoxoli.org> Signed-off-by: Terran McCanna <tmccanna@georgialibraries.org> diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/AssetCommon.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/AssetCommon.pm index 9c4c992e73..c9ed001b89 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/AssetCommon.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/AssetCommon.pm @@ -500,24 +500,26 @@ sub update_fleshed_copies { # Have to watch out for multiple items creating the same new part or the whole update will fail # This volume isn't necessarily the only volume with the same new part in this batch, so we have to check against the actual database. # Grab all the parts on the same record and then check the potentially new part against them all. - my $preexisting_parts = $editor->search_biblio_monograph_part({ - record => $vol->record, - label => (map { $_->label } @$parts), - deleted => 'f' - }); - - for my $part (@$parts) { - next unless $part->isnew; - - # Second check in case we hit a part matching a different part in this batch - my @existing = grep { $_->label eq $part->label && $_->record == $part->record } @$preexisting_parts; - if (@existing) { - # We've created this part previously, don't want to do that again. - my $oldnewthing = $existing[0]; - if ($oldnewthing->id) { - $part->id($oldnewthing->id); - $part->isnew(0); - $part->ischanged(0); + if (scalar @$parts) { + my $preexisting_parts = $editor->search_biblio_monograph_part({ + record => $vol->record, + label => (map { $_->label } @$parts), + deleted => 'f' + }); + + for my $part (@$parts) { + next unless $part->isnew; + + # Second check in case we hit a part matching a different part in this batch + my @existing = grep { $_->label eq $part->label && $_->record == $part->record } @$preexisting_parts; + if (@existing) { + # We've created this part previously, don't want to do that again. + my $oldnewthing = $existing[0]; + if ($oldnewthing->id) { + $part->id($oldnewthing->id); + $part->isnew(0); + $part->ischanged(0); + } } } } ----------------------------------------------------------------------- Summary of changes: Open-ILS/examples/fm_IDL.xml | 8 +- Open-ILS/src/eg2/src/app/core/idl.service.ts | 55 + Open-ILS/src/eg2/src/app/core/idl.spec.ts | 123 ++ .../src/eg2/src/app/core/server-store.service.ts | 34 +- .../src/app/share/combobox/combobox.component.html | 4 +- .../src/app/share/combobox/combobox.component.ts | 38 + .../src/eg2/src/app/share/common-widgets.module.ts | 3 + .../eg2/src/app/share/dialog/dialog.component.ts | 53 +- .../eg2/src/app/share/grid/grid-body.component.ts | 8 + .../share/grid/grid-toolbar-button.component.ts | 5 + .../src/app/share/grid/grid-toolbar.component.html | 8 +- .../src/app/share/grid/grid-toolbar.component.ts | 6 +- .../src/eg2/src/app/share/grid/grid.component.css | 18 +- .../src/eg2/src/app/share/grid/grid.component.html | 6 +- Open-ILS/src/eg2/src/app/share/grid/grid.ts | 10 +- .../app/share/org-select/org-select.component.html | 6 +- .../app/share/org-select/org-select.component.ts | 71 +- .../eg2/src/app/share/toast/toast.component.css | 2 +- .../eg2/src/app/share/util/broadcast.service.ts | 45 +- .../src/app/share/util/button-style.directive.ts | 56 + .../eg2/src/app/share/util/file-export.service.ts | 1 + .../admin/acq/currency/currencies.component.ts | 6 +- .../distribution-formulas.component.ts | 6 +- .../acq/edi_attr_set/edi-attr-sets.component.ts | 6 +- .../admin/acq/funds/funding-sources.component.ts | 6 +- .../admin/acq/funds/funds-manager.component.ts | 6 +- .../staff/admin/local/admin-carousel.component.ts | 6 +- .../admin/local/admin-local-splash.component.html | 4 +- .../circ-matrix-matchpoint.component.html | 2 +- .../search-filter/search-filter-group.component.ts | 6 +- .../staff-portal-page.component.html | 2 +- .../staff-portal-page.component.ts | 6 +- .../floating-group/floating-group.component.ts | 6 +- .../eg2/src/app/staff/cat/item/routing.module.ts | 4 + .../app/staff/cat/volcopy/config.component.html | 98 +- .../app/staff/cat/volcopy/copy-attrs.component.css | 230 ++++ .../staff/cat/volcopy/copy-attrs.component.html | 791 +++++++---- .../staff/cat/volcopy/copy-attrs.component.spec.ts | 8 +- .../app/staff/cat/volcopy/copy-attrs.component.ts | 1387 +++++++++++++++----- .../src/app/staff/cat/volcopy/routing.module.ts | 14 + .../staff/cat/volcopy/template-edit.component.html | 5 + .../staff/cat/volcopy/template-edit.component.ts | 170 +++ .../staff/cat/volcopy/template-grid.component.html | 155 +++ .../staff/cat/volcopy/template-grid.component.ts | 435 ++++++ .../app/staff/cat/volcopy/vol-edit.component.html | 4 +- .../app/staff/cat/volcopy/vol-edit.component.ts | 3 + .../app/staff/cat/volcopy/volcopy.component.css | 4 + .../app/staff/cat/volcopy/volcopy.component.html | 51 +- .../src/app/staff/cat/volcopy/volcopy.component.ts | 97 +- .../src/app/staff/cat/volcopy/volcopy.module.ts | 4 + .../src/app/staff/cat/volcopy/volcopy.service.ts | 250 +++- .../src/eg2/src/app/staff/cat/volcopy/volcopy.ts | 360 ++++- .../app/staff/catalog/record/copies.component.html | 2 +- .../app/staff/catalog/record/holdings.component.ts | 32 +- .../app/staff/circ/checkin/checkin.component.ts | 4 +- .../app/staff/circ/patron/checkout.component.ts | 3 +- .../src/app/staff/circ/renew/renew.component.ts | 2 - .../share/admin-page/admin-page.component.html | 4 +- .../staff/share/admin-page/admin-page.component.ts | 13 +- .../eg2/src/app/staff/share/circ/grid.component.ts | 2 +- .../share/holdings/batch-item-attr.component.css | 23 + .../share/holdings/batch-item-attr.component.html | 151 ++- .../share/holdings/batch-item-attr.component.ts | 69 +- .../share/holdings/copy-alert-manager.component.ts | 3 +- .../holdings/copy-alerts-dialog.component.html | 279 ++-- .../share/holdings/copy-alerts-dialog.component.ts | 557 ++++---- .../share/holdings/copy-alerts-page.component.html | 10 + .../share/holdings/copy-alerts-page.component.ts | 46 + .../share/holdings/copy-notes-dialog.component.css | 17 + .../holdings/copy-notes-dialog.component.html | 252 ++-- .../share/holdings/copy-notes-dialog.component.ts | 401 +++--- .../share/holdings/copy-tags-dialog.component.html | 108 +- .../share/holdings/copy-tags-dialog.component.ts | 617 ++++++--- .../copy-things-dialog-wrapper.component.html | 28 + .../copy-things-dialog-wrapper.component.ts | 18 + .../holdings/copy-things-dialog.component.html | 44 + .../share/holdings/copy-things-dialog.component.ts | 537 ++++++++ .../app/staff/share/holdings/holdings.module.ts | 15 +- .../app/staff/share/holdings/holdings.service.ts | 3 +- .../share/holdings/tag-map-list.component.css | 25 + .../share/holdings/tag-map-list.component.html | 74 ++ .../staff/share/holdings/tag-map-list.component.ts | 210 +++ Open-ILS/src/eg2/src/styles-colors.css | 1 + Open-ILS/src/eg2/src/styles.css | 242 ++-- .../lib/OpenILS/Application/Cat/AssetCommon.pm | 38 +- Open-ILS/src/sql/Pg/002.schema.config.sql | 2 +- Open-ILS/src/sql/Pg/950.data.seed-values.sql | 120 ++ .../upgrade/1463.data.org-setting-template-bar.sql | 63 + .../web/js/ui/default/staff/cat/volcopy/app.js | 67 +- .../web/js/ui/default/staff/circ/services/circ.js | 16 +- 90 files changed, 6971 insertions(+), 1819 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/share/util/button-style.directive.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/volcopy/copy-attrs.component.css create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/volcopy/template-edit.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/volcopy/template-edit.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/volcopy/template-grid.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/volcopy/template-grid.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/volcopy/volcopy.component.css create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/batch-item-attr.component.css create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-page.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/copy-alerts-page.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/copy-notes-dialog.component.css create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/copy-things-dialog-wrapper.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/copy-things-dialog-wrapper.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/copy-things-dialog.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/copy-things-dialog.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/tag-map-list.component.css create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/tag-map-list.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/share/holdings/tag-map-list.component.ts create mode 100644 Open-ILS/src/sql/Pg/upgrade/1463.data.org-setting-template-bar.sql hooks/post-receive -- Evergreen ILS
participants (1)
-
Git User