Bug 33066: Introduce a KohaTable Vue component

The way we deal with DataTables in Vue component is not nice, especially when we
need to add buttons/link in the cell and interact with the rest of the Vue app from
there.

When I started to work on Vue last year there was no good solution from DataTables,
now there is a Vue component. It is not perfect, you still cannot add Vue component
in the DT component, but it brings something to follow. Agustin implemented something
on theke/import_source_vue, but he went too far, and it will need to rewrite the
whole ERM module. Additionally he didn't provide a solution that has the same features
as what we have now.

The goal of this patch is to not duplicate the code in datatables.js, we
don't want to maintain two version of this code (one is enough already!)
We split the huge function in datatables.js in small ones to make them
reusable from the Vue component.

This is quite ugly, and it needs to lot more addition, but it's a first
start!

Help, ideas, and feedback welcome (and needed!)

Bug 33066: Fix agreement name in delete confirmation dialog

Signed-off-by: Pedro Amorim <pedro.amorim@ptfs-europe.com>

Signed-off-by: Agustín Moyano <agustinmoyano@theke.io>
Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>
(cherry picked from commit 765fd1ced3)
Signed-off-by: Martin Renvoize <martin.renvoize@ptfs-europe.com>
This commit is contained in:
Jonathan Druart 2023-02-24 13:14:19 +01:00 committed by Martin Renvoize
parent 28b0b43544
commit 498a37f229
Signed by: martin.renvoize
GPG key ID: 422B469130441A0F
5 changed files with 723 additions and 394 deletions

View file

@ -504,47 +504,14 @@ jQuery.fn.dataTable.ext.errMode = function(settings, note, message) {
console.log(message);
};
(function($) {
function _dt_default_ajax (params){
let default_filters = params.default_filters;
let options = params.options;
/**
* Create a new dataTables instance that uses the Koha RESTful API's as a data source
* @param {Object} options Please see the dataTables documentation for further details
* We extend the options set with the `criteria` key which allows
* the developer to select the match type to be applied during searches
* Valid keys are: `contains`, `starts_with`, `ends_with` and `exact`
* @param {Object} table_settings The arrayref as returned by TableSettings.GetTableSettings function available
* from the columns_settings template toolkit include
* @param {Boolean} add_filters Add a filters row as the top row of the table
* @param {Object} default_filters Add a set of default search filters to apply at table initialisation
* @return {Object} The dataTables instance
*/
$.fn.kohaTable = function(options, table_settings, add_filters, default_filters) {
var settings = null;
if(options) {
if(!options.criteria || ['contains', 'starts_with', 'ends_with', 'exact'].indexOf(options.criteria.toLowerCase()) === -1) options.criteria = 'contains';
options.criteria = options.criteria.toLowerCase();
// Don't redefine the default initComplete
if ( options.initComplete ) {
let our_initComplete = options.initComplete;
options.initComplete = function(settings, json){
our_initComplete(settings, json);
dataTablesDefaults.initComplete(settings, json)
};
}
settings = $.extend(true, {}, dataTablesDefaults, {
'deferRender': true,
"paging": true,
'serverSide': true,
'searching': true,
'pagingType': 'full_numbers',
'processing': true,
'language': {
'emptyTable': (options.emptyTable) ? options.emptyTable : __("No data available in table")
},
'ajax': {
return {
'type': 'GET',
'cache': true,
'dataSrc': 'data',
@ -590,7 +557,7 @@ jQuery.fn.dataTable.ext.errMode = function(settings, note, message) {
value = value.replace(/^\^/, '').replace(/\$$/, '');
criteria = "exact";
} else {
// escape SQL LIKE special characters % and _
// escape SQL LIKE special characters %
value = value.replace(/(\%|\\)/g, "\\$1");
}
part[!attr.includes('.')?'me.'+attr:attr] = criteria === 'exact'
@ -690,30 +657,12 @@ jQuery.fn.dataTable.ext.errMode = function(settings, note, message) {
return dataSet;
}
}
}, options);
}
var counter = 0;
var hidden_ids = [];
var included_ids = [];
if ( table_settings ) {
var columns_settings = table_settings['columns'];
$(columns_settings).each( function() {
var named_id = $( 'thead th[data-colname="' + this.columnname + '"]', this ).index( 'th' );
var used_id = settings.bKohaColumnsUseNames ? named_id : counter;
if ( used_id == -1 ) return;
if ( this['is_hidden'] == "1" ) {
hidden_ids.push( used_id );
}
if ( this['cannot_be_toggled'] == "0" ) {
included_ids.push( used_id );
}
counter++;
});
}
function _dt_buttons(params){
let included_ids = params.included_ids || [];
let settings = params.settings || {};
let table_settings = params.table_settings || {};
var exportColumns = ":visible:not(.noExport)";
if( settings.hasOwnProperty("exportColumns") ){
@ -769,7 +718,8 @@ jQuery.fn.dataTable.ext.errMode = function(settings, note, message) {
}
];
settings[ "buttons" ] = [
let buttons = [];
buttons.push(
{
fade: 100,
className: "dt_button_clear_filter",
@ -781,10 +731,10 @@ jQuery.fn.dataTable.ext.errMode = function(settings, note, message) {
node.addClass("disabled");
}
}
];
);
if( included_ids.length > 0 ){
settings[ "buttons" ].push(
buttons.push(
{
extend: 'colvis',
fade: 100,
@ -799,7 +749,7 @@ jQuery.fn.dataTable.ext.errMode = function(settings, note, message) {
);
}
settings[ "buttons" ].push(
buttons.push(
{
extend: 'collection',
autoClose: true,
@ -812,7 +762,7 @@ jQuery.fn.dataTable.ext.errMode = function(settings, note, message) {
);
if ( table_settings && CAN_user_parameters_manage_column_config ) {
settings[ "buttons" ].push(
buttons.push(
{
className: "dt_button_configure_table",
fade: 100,
@ -825,30 +775,57 @@ jQuery.fn.dataTable.ext.errMode = function(settings, note, message) {
);
}
$(".dt_button_clear_filter, .columns_controls, .export_controls, .dt_button_configure_table").tooltip();
if ( add_filters ) {
settings['orderCellsTop'] = true;
return buttons;
}
function _dt_visibility(table_settings, settings){
var counter = 0;
let hidden_ids = [];
let included_ids = [];
if ( table_settings ) {
if ( table_settings.hasOwnProperty('default_display_length') && table_settings['default_display_length'] != null ) {
settings["pageLength"] = table_settings['default_display_length'];
var columns_settings = table_settings['columns'];
$(columns_settings).each( function() {
var named_id = $( 'thead th[data-colname="' + this.columnname + '"]', this ).index( 'th' );
var used_id = settings.bKohaColumnsUseNames ? named_id : counter;
if ( used_id == -1 ) return;
if ( this['is_hidden'] == "1" ) {
hidden_ids.push( used_id );
}
if ( table_settings.hasOwnProperty('default_sort_order') && table_settings['default_sort_order'] != null ) {
settings["order"] = [[ table_settings['default_sort_order'], 'asc' ]];
if ( this['cannot_be_toggled'] == "0" ) {
included_ids.push( used_id );
}
counter++;
});
}
return [hidden_ids, included_ids];
}
var table = $(this).dataTable(settings);
function _dt_on_visibility(add_filters, table_node, table_dt){
if ( add_filters ) {
var table_dt = table.DataTable();
let visible_columns = table_dt.columns().visible();
$(table_node).find('thead tr:eq(1) th').each( function (i) {
let th_id = $(this).data('th-id');
if ( visible_columns[th_id] == false ) {
$(this).hide();
} else {
$(this).show();
}
});
}
$(this).find('thead tr').clone().appendTo( $(this).find('thead') );
if( typeof columnsInit == 'function' ){
// This function can be created separately and used to trigger
// an event after the DataTable has loaded AND column visibility
// has been updated according to the table's configuration
columnsInit();
}
}
$(this).find('thead tr:eq(1) th').each( function (i) {
function _dt_add_filters(table_node, table_dt) {
$(table_node).find('thead tr').clone().appendTo( $(table_node).find('thead') );
$(table_node).find('thead tr:eq(1) th').each( function (i) {
var is_searchable = table_dt.settings()[0].aoColumns[i].bSearchable;
$(this).removeClass('sorting').removeClass("sorting_asc").removeClass("sorting_desc");
$(this).data('th-id', i);
@ -875,7 +852,7 @@ jQuery.fn.dataTable.ext.errMode = function(settings, note, message) {
if ( existing_search ) {
$(this).html( '<input type="text" value="%s" style="width: 100%" />'.format(existing_search) );
} else {
var search_title = __("%s search").format(title);
var search_title = _("%s search").format(title);
$(this).html( '<input type="text" placeholder="%s" style="width: 100%" />'.format(search_title) );
}
}
@ -901,26 +878,77 @@ jQuery.fn.dataTable.ext.errMode = function(settings, note, message) {
} );
}
table.DataTable().on("column-visibility.dt", function(){
if ( add_filters ) {
let visible_columns = table_dt.columns().visible();
$(table).find('thead tr:eq(1) th').each( function (i) {
let th_id = $(this).data('th-id');
if ( visible_columns[th_id] == false ) {
$(this).hide();
} else {
$(this).show();
}
});
(function($) {
/**
* Create a new dataTables instance that uses the Koha RESTful API's as a data source
* @param {Object} options Please see the dataTables documentation for further details
* We extend the options set with the `criteria` key which allows
* the developer to select the match type to be applied during searches
* Valid keys are: `contains`, `starts_with`, `ends_with` and `exact`
* @param {Object} table_settings The arrayref as returned by TableSettings.GetTableSettings function available
* from the columns_settings template toolkit include
* @param {Boolean} add_filters Add a filters row as the top row of the table
* @param {Object} default_filters Add a set of default search filters to apply at table initialisation
* @return {Object} The dataTables instance
*/
$.fn.kohaTable = function(options, table_settings, add_filters, default_filters) {
var settings = null;
if(options) {
// Don't redefine the default initComplete
if ( options.initComplete ) {
let our_initComplete = options.initComplete;
options.initComplete = function(settings, json){
our_initComplete(settings, json);
dataTablesDefaults.initComplete(settings, json)
};
}
if( typeof columnsInit == 'function' ){
// This function can be created separately and used to trigger
// an event after the DataTable has loaded AND column visibility
// has been updated according to the table's configuration
columnsInit();
settings = $.extend(true, {}, dataTablesDefaults, {
'deferRender': true,
"paging": true,
'serverSide': true,
'searching': true,
'pagingType': 'full_numbers',
'processing': true,
'language': {
'emptyTable': (options.emptyTable) ? options.emptyTable : __("No data available in table")
},
'ajax': _dt_default_ajax({default_filters, options}),
}, options);
}
}).columns( hidden_ids ).visible( false );
let hidden_ids, included_ids;
[hidden_ids, included_ids] = _dt_visibility(table_settings, settings)
settings["buttons"] = _dt_buttons({included_ids, settings, table_settings});
$(".dt_button_clear_filter, .columns_controls, .export_controls, .dt_button_configure_table").tooltip();
if ( add_filters ) {
settings['orderCellsTop'] = true;
}
if ( table_settings ) {
if ( table_settings.hasOwnProperty('default_display_length') && table_settings['default_display_length'] != null ) {
settings["pageLength"] = table_settings['default_display_length'];
}
if ( table_settings.hasOwnProperty('default_sort_order') && table_settings['default_sort_order'] != null ) {
settings["order"] = [[ table_settings['default_sort_order'], 'asc' ]];
}
}
var table = $(this).dataTable(settings);
var table_dt = table.DataTable();
if ( add_filters ) {
_dt_add_filters(this, table_dt);
}
table.DataTable().on("column-visibility.dt", function(){_dt_on_visibility(add_filters, table, table_dt);})
.columns( hidden_ids ).visible( false );
return table;
};

View file

@ -33,7 +33,13 @@
/>
</fieldset>
<div v-if="agreement_count > 0" class="page-section">
<table :id="table_id"></table>
<KohaTable
ref="table"
v-bind="tableOptions"
@show="doShow"
@edit="doEdit"
@delete="doDelete"
></KohaTable>
</div>
<div v-else class="dialog message">
{{ $__("There are no agreements defined") }}
@ -44,10 +50,11 @@
<script>
import flatPickr from "vue-flatpickr-component"
import Toolbar from "./AgreementsToolbar.vue"
import { inject, createVNode, render } from "vue"
import { inject, createVNode, render, ref } from "vue"
import { APIClient } from "../../fetch/api-client.js"
import { storeToRefs } from "pinia"
import { useDataTable, build_url } from "../../composables/datatables"
import { build_url } from "../../composables/datatables"
import KohaTable from "../KohaTable.vue"
export default {
setup() {
@ -59,17 +66,18 @@ export default {
const { setConfirmationDialog, setMessage } = inject("mainStore")
const table_id = "agreement_list"
useDataTable(table_id)
const table = ref()
return {
vendors,
get_lib_from_av,
map_av_dt_filter,
table_id,
logged_in_user,
table,
setConfirmationDialog,
setMessage,
logged_in_user,
escape_str,
agreement_table_settings,
}
},
data: function () {
@ -85,6 +93,16 @@ export default {
},
before_route_entered: false,
building_table: false,
tableOptions: {
columns: this.getTableColumns(),
url: () => this.table_url(),
table_settings: this.agreement_table_settings,
add_filters: true,
actions: {
0: ["show"],
"-1": ["edit", "delete"],
},
},
}
},
beforeRouteEnter(to, from, next) {
@ -92,75 +110,70 @@ export default {
vm.before_route_entered = true // FIXME This is ugly, but we need to distinguish when it's used as main component or child component (from EHoldingsEBSCOPAckagesShow for instance)
if (!vm.building_table) {
vm.building_table = true
vm.getAgreementCount().then(() => vm.build_datatable())
vm.getAgreementCount()
vm.initialized = true
}
})
},
computed: {
datatable_url() {
let url = "/api/v1/erm/agreements"
if (this.filters.by_expired)
url +=
"?max_expiration_date=" + this.filters.max_expiration_date
return url
},
},
methods: {
async getAgreementCount() {
const client = APIClient.erm
await client.agreements.count().then(
count => {
this.agreement_count = count
this.initialized = true
},
error => {}
)
},
show_agreement: function (agreement_id) {
this.$router.push("/cgi-bin/koha/erm/agreements/" + agreement_id)
},
edit_agreement: function (agreement_id) {
doShow: function (agreement, dt, event) {
event.preventDefault()
this.$router.push(
"/cgi-bin/koha/erm/agreements/edit/" + agreement_id
"/cgi-bin/koha/erm/agreements/" + agreement.agreement_id
)
},
delete_agreement: function (agreement_id, agreement_name) {
doEdit: function (agreement, dt, event) {
this.$router.push(
"/cgi-bin/koha/erm/agreements/edit/" + agreement.agreement_id
)
},
doDelete: function (agreement, dt, event) {
this.setConfirmationDialog(
{
title: this.$__(
"Are you sure you want to remove this agreement?"
"Are you sure you want to delete this agreement?"
),
message: agreement_name,
message: agreement.name,
accept_label: this.$__("Yes, delete"),
cancel_label: this.$__("No, do not delete"),
},
() => {
const client = APIClient.erm
client.agreements.delete(agreement_id).then(
client.agreements.delete(agreement.agreement_id).then(
success => {
this.setMessage(
this.$__("Agreement %s deleted").format(
agreement_name
agreement.name
),
true
)
this.refresh_table()
dt.draw()
},
error => {}
)
}
)
},
table_url: function () {
let url = "/api/v1/erm/agreements"
if (this.filters.by_expired)
url +=
"?max_expiration_date=" + this.filters.max_expiration_date
return url
},
select_agreement: function (agreement_id) {
this.$emit("select-agreement", agreement_id)
this.$emit("close")
},
refresh_table: function () {
$("#" + this.table_id)
.DataTable()
.ajax.url(this.datatable_url)
.draw()
},
filter_table: async function () {
if (this.before_route_entered) {
let new_route = build_url(
@ -175,9 +188,8 @@ export default {
.toISOString()
.substring(0, 10)
}
this.refresh_table()
this.$refs.table.redraw(this.table_url())
},
table_url: function () {},
build_datatable: function () {
let show_agreement = this.show_agreement
let edit_agreement = this.edit_agreement
@ -477,14 +489,131 @@ export default {
additional_filters
)
},
getTableColumns: function () {
let get_lib_from_av = this.get_lib_from_av
let escape_str = this.escape_str
window["vendors"] = this.vendors.map(e => {
e["_id"] = e["id"]
e["_str"] = e["name"]
return e
})
let vendors_map = this.vendors.reduce((map, e) => {
map[e.id] = e
return map
}, {})
let avs = [
"av_agreement_statuses",
"av_agreement_closure_reasons",
"av_agreement_renewal_priorities",
]
let c = this
avs.forEach(function (av_cat) {
window[av_cat] = c.map_av_dt_filter(av_cat)
})
window["av_agreement_is_perpetual"] = [
{ _id: 0, _str: _("No") },
{ _id: 1, _str: _("Yes") },
]
return [
{
title: __("Name"),
data: "me.agreement_id:me.name",
searchable: true,
orderable: true,
render: function (data, type, row, meta) {
// Rendering done in drawCallback
return (
'<a href="/cgi-bin/koha/erm/agreements/' +
row.agreement_id +
'" class="show">show</a>'
)
},
},
{
title: __("Vendor"),
data: "vendor_id",
searchable: true,
orderable: true,
render: function (data, type, row, meta) {
return row.vendor_id != undefined
? escape_str(vendors_map[row.vendor_id].name)
: ""
},
},
{
title: __("Description"),
data: "description",
searchable: true,
orderable: true,
},
{
title: __("Status"),
data: "status",
searchable: true,
orderable: true,
render: function (data, type, row, meta) {
return escape_str(
get_lib_from_av("av_agreement_statuses", row.status)
)
},
},
{
title: __("Closure reason"),
data: "closure_reason",
searchable: true,
orderable: true,
render: function (data, type, row, meta) {
return escape_str(
get_lib_from_av(
"av_agreement_closure_reasons",
row.closure_reason
)
)
},
},
{
title: __("Is perpetual"),
data: "is_perpetual",
searchable: true,
orderable: true,
render: function (data, type, row, meta) {
return escape_str(row.is_perpetual ? _("Yes") : _("No"))
},
},
{
title: __("Renewal priority"),
data: "renewal_priority",
searchable: true,
orderable: true,
render: function (data, type, row, meta) {
return escape_str(
get_lib_from_av(
"av_agreement_renewal_priorities",
row.renewal_priority
)
)
},
},
{
title: __("Actions"),
data: function (row, type, val, meta) {
return '<div class="actions"></div>'
},
className: "actions noExport",
searchable: false,
orderable: false,
},
]
},
},
mounted() {
if (!this.building_table) {
this.building_table = true
this.getAgreementCount().then(() => this.build_datatable())
this.getAgreementCount()
}
},
components: { flatPickr, Toolbar },
components: { flatPickr, Toolbar, KohaTable },
name: "AgreementsList",
emits: ["select-agreement", "close"],
}

View file

@ -0,0 +1,142 @@
<template>
<DataTable
:columns="tableColumns"
:options="{ ...dataTablesDefaults, ...allOptions }"
:data="data"
ref="table"
>
<slot></slot>
</DataTable>
</template>
<script>
import DataTable from "datatables.net-vue3"
import DataTablesLib from "datatables.net"
import "datatables.net-buttons"
import "datatables.net-buttons/js/buttons.html5"
import "datatables.net-buttons/js/buttons.print"
DataTable.use(DataTablesLib)
export default {
name: "KohaTable",
data() {
return {
data: [],
tableColumns: this.columns,
allOptions: {
deferRender: true,
paging: true,
serverSide: true,
searching: true,
pagingType: "full_numbers",
processing: true,
ajax: {
url: typeof this.url === "function" ? this.url() : this.url,
..._dt_default_ajax({ options: this.options }),
},
buttons: _dt_buttons({ table_settings: this.table_settings }),
default_search: this.$route.query.q,
},
}
},
setup() {
return { dataTablesDefaults }
},
methods: {
redraw: function (url) {
this.$refs.table.dt().ajax.url(url).draw()
},
},
beforeMount() {
if (this.actions.hasOwnProperty("-1")) {
let actions = this.actions["-1"]
this.tableColumns = [
...this.tableColumns,
{
name: "actions",
title: this.$__("Actions"),
searchable: false,
render: (data, type, row) => {
let content = []
this.actions["-1"].forEach(a => {
if (a == "edit") {
content.push(
'<a class="edit btn btn-default btn-xs" role="button"><i class="fa fa-pencil"></i>' +
this.$__("Edit") +
"</a>"
)
} else if (a == "delete") {
content.push(
'<a class="delete btn btn-default btn-xs" role="button"><i class="fa fa-trash"></i>' +
this.$__("Delete") +
"</a>"
)
}
})
return content.join(" ")
},
},
]
}
},
mounted() {
if (Object.keys(this.actions).length) {
const dt = this.$refs.table.dt()
const self = this
dt.on("draw", () => {
const dataSet = dt.rows().data()
Object.entries(this.actions).forEach(([col_id, actions]) => {
dt.column(col_id)
.nodes()
.to$()
.each(function (idx) {
const data = dataSet[idx]
actions.forEach(action => {
$("." + action, this).on("click", e => {
self.$emit(action, data, dt, e)
})
})
})
})
})
}
},
beforeUnmount() {
const dt = this.$refs.table.dt()
dt.destroy()
},
components: {
DataTable,
},
props: {
url: {
type: [String, Function],
default: "",
},
columns: {
type: Array,
default: [],
},
actions: {
type: Object,
default: {},
},
options: {
type: Object,
default: {},
},
default_filters: {
type: Object,
required: false,
},
table_settings: {
type: Object,
required: false,
},
default_search: {
type: String,
required: false,
},
},
}
</script>

View file

@ -18,6 +18,8 @@
"bootstrap": "^4.5.2",
"css-loader": "^6.6.0",
"cypress": "^9.5.2",
"datatables.net-buttons": "^2.3.4",
"datatables.net-vue3": "^2.0.0",
"gulp": "^4.0.2",
"gulp-autoprefixer": "^4.0.0",
"gulp-concat-po": "^1.0.0",

View file

@ -3518,6 +3518,29 @@ dashdash@^1.12.0:
dependencies:
assert-plus "^1.0.0"
datatables.net-buttons@^2.3.4:
version "2.3.4"
resolved "https://registry.yarnpkg.com/datatables.net-buttons/-/datatables.net-buttons-2.3.4.tgz#85b88baed81d380cb04c06608d549c8868326ece"
integrity sha512-1fe/aiKBdKbwJ5j0OobP2dzhbg/alGOphnTfLFGaqlP5yVxDCfcZ9EsuglYeHRJ/KnU7DZ8BgsPFiTE0tOFx8Q==
dependencies:
datatables.net ">=1.12.1"
jquery ">=1.7"
datatables.net-vue3@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/datatables.net-vue3/-/datatables.net-vue3-2.0.0.tgz#01b20c68201f49f57920dea62a5f742b7119880b"
integrity sha512-auwLfwqebGZ0gFnU8C/HWQYpkVtU64x8T+gYs5i7/Jqyo3YNTDU2M/lWwp7rJ+VSlolkDICrKpfmmo/Rz6ZBFw==
dependencies:
datatables.net "^1.13.1"
jquery "^3.6.0"
datatables.net@>=1.12.1, datatables.net@^1.13.1:
version "1.13.2"
resolved "https://registry.yarnpkg.com/datatables.net/-/datatables.net-1.13.2.tgz#48f7035b1696a29cb70909db1f2e0ebd5f946f3e"
integrity sha512-u5nOU+C9SBp1SyPmd6G+niozZtrBwo1E8xzdOk3JJaAkFYgX/KxF3Gd79R8YLbUfmIs2OLnLe5gaz/qs5U8UDA==
dependencies:
jquery ">=1.7"
dayjs@^1.10.4:
version "1.11.5"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.5.tgz#00e8cc627f231f9499c19b38af49f56dc0ac5e93"
@ -5935,6 +5958,11 @@ joi@^17.4.0:
"@sideway/formula" "^3.0.0"
"@sideway/pinpoint" "^2.0.0"
jquery@>=1.7, jquery@^3.6.0:
version "3.6.3"
resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.3.tgz#23ed2ffed8a19e048814f13391a19afcdba160e6"
integrity sha512-bZ5Sy3YzKo9Fyc8wH2iIQK4JImJ6R0GWI9kL1/k7Z91ZBNgkRXE6U0JfHIizZbort8ZunhSI3jw9I6253ahKfg==
js-message@1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/js-message/-/js-message-1.0.7.tgz#fbddd053c7a47021871bb8b2c95397cc17c20e47"