@saltcorn/reservable 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,36 +2,34 @@
2
2
 
3
3
  To use this Plugin, you should create a table for reservations. This table should have the following fields:
4
4
 
5
- * A Date field for the time of the start of the reservation
6
- * An integer field for the duration After reservation in minutes
7
- * Any other fields for the reservation, for instance the email of the person reserving the slot
5
+ - A Date field for the time of the start of the reservation
6
+ - An integer field for the duration of the reservation in minutes
7
+ - Any other fields for the reservation, for instance the email of the person reserving the slot
8
8
 
9
9
  This table also needs to have two views
10
10
 
11
- * A view to create, typically an Edit view obtain the information required for the booking.
11
+ - A view to create, typically an Edit view obtain the information required for the booking.
12
12
  This Edit view does not need to have fields for the date or the duration.
13
- * A confirmation view, typically a Show view, which is displayed to the user on a successful reservation.
13
+ - A confirmation view, typically a Show view, which is displayed to the user on a successful reservation.
14
14
  This may or may not contain Date and duration
15
15
 
16
16
  There may be another table indicating reservable resources. This is optional. If you are building a reservation system
17
- for a single resource, you can leave this out. On the other hand, if you are building a system where multiple resources
18
- need to be tracked for whether they are available, use this to denote the resources that can be taken/available.
17
+ for a single resource, you can leave this out. On the other hand, if you are building a system where multiple resources
18
+ need to be tracked for whether they are available, use this to denote the resources that can be taken/available.
19
19
  To do this, create a foreign key field on your reservations Table to the reservable resource table.
20
20
 
21
21
  You should then create a Reserve view on the reservations table. In the configuration, you specify the fields
22
22
  As explained above. You will also need to configure:
23
23
 
24
- * What services are available. Each service has a name and a duration.
25
- * What the availability is, your opening hours. You specify these in blocks of time and the day of the week.
24
+ - What services are available. Each service has a name and a duration.
25
+ - What the availability is, your opening hours. You specify these in blocks of time and the day of the week.
26
26
 
27
27
  ### Example
28
28
 
29
- Goldilocks and Rapunzel together run the successful GoldiRap Hair Salon. In fact they are so successful that
30
- they must now implement an online booking system. Their opening hours are Monday to Friday 9-12 and 13-17
31
- (they close for lunch) and Saturday 10-13. They offer two services: A haircut taking 30 minutes
29
+ Goldilocks and Rapunzel together run the successful GoldiRap Hair Salon. In fact they are so successful that
30
+ they must now implement an online booking system. Their opening hours are Monday to Friday 9-12 and 13-17
31
+ (they close for lunch) and Saturday 10-13. They offer two services: A haircut taking 30 minutes
32
32
  and a full restyle taking 45 minutes. You can book either Goldilocks or Rapunzel for each of the services.
33
33
 
34
- Here, the reservable resource is the hairdresser with two rows: Goldilocks and Rapunzel. They have two services,
34
+ Here, the reservable resource is the hairdresser with two rows: Goldilocks and Rapunzel. They have two services,
35
35
  haircut and full restyle. The availability can be specified as: Mon-Fri 9-12, Mon-Fri 13-17 and Saturday 10-13.
36
-
37
-
@@ -0,0 +1,277 @@
1
+ const {
2
+ input,
3
+ div,
4
+ text,
5
+ script,
6
+ domReady,
7
+ style,
8
+ button,
9
+ h3,
10
+ ul,
11
+ li,
12
+ form,
13
+ a,
14
+ b,
15
+ } = require("@saltcorn/markup/tags");
16
+
17
+ const View = require("@saltcorn/data/models/view");
18
+ const Workflow = require("@saltcorn/data/models/workflow");
19
+ const Table = require("@saltcorn/data/models/table");
20
+ const Form = require("@saltcorn/data/models/form");
21
+ const Field = require("@saltcorn/data/models/field");
22
+ const {
23
+ InvalidConfiguration,
24
+ isNode,
25
+ isWeb,
26
+ mergeConnectedObjects,
27
+ hashState,
28
+ } = require("@saltcorn/data/utils");
29
+ const {
30
+ link_view,
31
+ stateToQueryString,
32
+ stateFieldsToWhere,
33
+ stateFieldsToQuery,
34
+ readState,
35
+ } = require("@saltcorn/data/plugin-helper");
36
+ const get_state_fields = async (table_id, viewname, { show_view }) => {
37
+ const table = Table.findOne(table_id);
38
+ const table_fields = table.fields;
39
+ return table_fields
40
+ .filter((f) => !f.primary_key)
41
+ .map((f) => {
42
+ const sf = new Field(f);
43
+ sf.required = false;
44
+ return sf;
45
+ });
46
+ };
47
+
48
+ const configuration_workflow = (req) =>
49
+ new Workflow({
50
+ steps: [
51
+ {
52
+ name: req.__("Views"),
53
+ form: async (context) => {
54
+ const table = Table.findOne(context.table_id);
55
+ const fields = table.fields;
56
+
57
+ const reservable_entity_fields = fields.filter((f) => f.is_fkey);
58
+ const show_view_opts = {};
59
+ const slots_available_field = {};
60
+ const distinct_slot_fields = new Set();
61
+ for (const rfield of reservable_entity_fields) {
62
+ const retable = Table.findOne(rfield.reftable_name);
63
+ const show_views = await View.find_table_views_where(
64
+ retable.id,
65
+ ({ state_fields, viewtemplate, viewrow }) =>
66
+ viewtemplate.runMany &&
67
+ viewrow.name !== context.viewname &&
68
+ state_fields.some((sf) => sf.name === "id")
69
+ );
70
+ show_view_opts[rfield.name] = show_views.map((v) => v.name);
71
+ slots_available_field[rfield.name] = retable.fields
72
+ .filter((f) => f.type?.name === "Integer")
73
+ .map((f) => f.name);
74
+ slots_available_field[rfield.name].forEach((v) =>
75
+ distinct_slot_fields.add(v)
76
+ );
77
+ slots_available_field[rfield.name].unshift("");
78
+ }
79
+ return new Form({
80
+ fields: [
81
+ {
82
+ name: "reservable_entity_key",
83
+ label: "Key to reservable entity",
84
+ type: "String",
85
+ required: true,
86
+ attributes: {
87
+ options: reservable_entity_fields.map((f) => f.name),
88
+ },
89
+ },
90
+ {
91
+ name: "valid_field",
92
+ label: "Valid reservation field",
93
+ sublabel: "Only consider reservations with this field valid",
94
+ type: "String",
95
+ attributes: {
96
+ options: fields
97
+ .filter((f) => f.type.name === "Bool")
98
+ .map((f) => f.name),
99
+ },
100
+ },
101
+ {
102
+ name: "start_field",
103
+ label: "Start date field",
104
+ type: "String",
105
+ required: true,
106
+ attributes: {
107
+ options: fields
108
+ .filter((f) => f.type.name === "Date")
109
+ .map((f) => f.name),
110
+ },
111
+ },
112
+ {
113
+ name: "end_field",
114
+ label: "End date field",
115
+ type: "String",
116
+ required: true,
117
+ attributes: {
118
+ options: fields
119
+ .filter((f) => f.type.name === "Date")
120
+ .map((f) => f.name),
121
+ },
122
+ },
123
+ /*{
124
+ name: "duration_field",
125
+ label: "Duration field",
126
+ sublabel: "Integer field holding booked duration in minutes",
127
+ type: "String",
128
+ attributes: {
129
+ options: fields
130
+ .filter((f) => f.type.name === "Integer")
131
+ .map((f) => f.name),
132
+ },
133
+ }, */
134
+ {
135
+ name: "show_view",
136
+ label: req.__("Single item view"),
137
+ type: "String",
138
+ sublabel:
139
+ req.__("The underlying individual view of each table row") +
140
+ ". " +
141
+ a(
142
+ {
143
+ "data-dyn-href": `\`/viewedit/config/\${show_view}\``,
144
+ target: "_blank",
145
+ },
146
+ req.__("Configure")
147
+ ),
148
+ required: true,
149
+ attributes: {
150
+ calcOptions: ["reservable_entity_key", show_view_opts],
151
+ },
152
+ },
153
+ {
154
+ name: "slots_available_field",
155
+ label: "Slots available field",
156
+ sublabel:
157
+ "Field with a number of available instances of the reservable entity. If blank, treat as one per entity.",
158
+ type: "String",
159
+ attributes: {
160
+ calcOptions: ["reservable_entity_key", slots_available_field],
161
+ },
162
+ },
163
+ {
164
+ name: "slot_count_field",
165
+ label: "Slots taken field",
166
+ sublabel:
167
+ "Field with the number of entities reserved. If blank, treat as one per entity.",
168
+ type: "String",
169
+ showIf: { slots_available_field: [...distinct_slot_fields] },
170
+ attributes: {
171
+ options: fields
172
+ .filter((f) => f.type.name === "Integer")
173
+ .map((f) => f.name),
174
+ },
175
+ },
176
+ ],
177
+ });
178
+ },
179
+ },
180
+ ],
181
+ });
182
+
183
+ const first = (xs) => (Array.isArray(xs) ? xs[0] : xs);
184
+
185
+ const run = async (
186
+ table_id,
187
+ viewname,
188
+ {
189
+ reservable_entity_key,
190
+ valid_field,
191
+ slot_count_field,
192
+ slots_available_field,
193
+ show_view,
194
+ start_field,
195
+ end_field,
196
+ },
197
+ state,
198
+ extraArgs
199
+ ) => {
200
+ const restable = Table.findOne({ id: table_id });
201
+ const resfields = restable.getFields();
202
+
203
+ const refield = restable.getField(reservable_entity_key);
204
+ const retable = Table.findOne(refield.reftable_name);
205
+
206
+ const state_res = { ...state };
207
+
208
+ readState(state_res, restable.fields);
209
+
210
+ //get reservations
211
+ const reswhere = await stateFieldsToWhere({
212
+ fields: resfields,
213
+ state: state_res,
214
+ table: restable,
215
+ });
216
+
217
+ if (valid_field) reswhere[valid_field] = true;
218
+ const reservations = await restable.getRows(reswhere);
219
+ const use_slots = slot_count_field || slots_available_field;
220
+ const sview = await View.findOne({ name: show_view });
221
+ if (!sview)
222
+ throw new InvalidConfiguration(
223
+ `View ${viewname} incorrectly configured: cannot find view ${show_view}`
224
+ );
225
+ const srespAll = await sview.runMany(state, extraArgs);
226
+ const srespsAvailable = [];
227
+
228
+ if (!use_slots) {
229
+ const resEnts = new Set(reservations.map((r) => r[reservable_entity_key]));
230
+ for (const sresp of srespAll) {
231
+ if (!resEnts.has(sresp.row[retable.pk_name])) srespsAvailable.push(sresp);
232
+ }
233
+ } else {
234
+ //console.log("state_res", state_res);
235
+ //console.log("reswhere", reswhere);
236
+ const to = new Date(first(reswhere[start_field])?.lt);
237
+ const from = new Date(first(reswhere[end_field])?.gt);
238
+ if (!from || !to) srespsAvailable.push(...srespAll);
239
+ else
240
+ for (const sresp of srespAll) {
241
+ const myreservations = reservations.filter(
242
+ (r) => r[reservable_entity_key] === sresp.row[retable.pk_name]
243
+ );
244
+ /*console.log({
245
+ taken: resEnts[sresp.row[retable.pk_name]] || 0,
246
+ available: sresp.row[slots_available_field],
247
+ });*/
248
+ const to = new Date(first(reswhere[start_field])?.lt);
249
+ const from = new Date(first(reswhere[end_field])?.gt);
250
+ let maxAvailable = sresp.row[slots_available_field];
251
+ for (let day = from; day <= to; day.setDate(day.getDate() + 1)) {
252
+ const active = myreservations.filter(
253
+ (r) => r[start_field] <= day && r[end_field] >= day
254
+ );
255
+ const taken = active
256
+ .map((r) => r[slot_count_field] || 1)
257
+ .reduce((a, b) => a + b, 0);
258
+ maxAvailable = Math.min(
259
+ maxAvailable,
260
+ sresp.row[slots_available_field] - taken
261
+ );
262
+ //console.log({ car: sresp.row.name, day, maxAvailable });
263
+ }
264
+ if (maxAvailable > 0) srespsAvailable.push(sresp);
265
+ }
266
+ }
267
+ const showRow = (r) => r.html;
268
+ return div(srespsAvailable.map(showRow));
269
+ };
270
+
271
+ module.exports = {
272
+ name: "Available Resources Feed",
273
+ display_state_form: false,
274
+ get_state_fields,
275
+ configuration_workflow,
276
+ run,
277
+ };
package/index.js CHANGED
@@ -167,7 +167,8 @@ const configuration_workflow = () =>
167
167
  });
168
168
 
169
169
  const get_state_fields = async (table_id, viewname, { show_view }) => {
170
- const table_fields = await Field.find({ table_id });
170
+ const table = Table.findOne(table_id);
171
+ const table_fields = table.fields;
171
172
  return table_fields.map((f) => {
172
173
  const sf = new Field(f);
173
174
  sf.required = false;
@@ -525,7 +526,9 @@ module.exports = {
525
526
  run,
526
527
  runPost,
527
528
  },
529
+ require("./available-feed"),
528
530
  ],
531
+ actions: { validate_reservation: require("./validate") },
529
532
  };
530
533
 
531
534
  /*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saltcorn/reservable",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Reservable resources",
5
5
  "main": "index.js",
6
6
  "dependencies": {
@@ -18,5 +18,21 @@
18
18
  },
19
19
  "scripts": {
20
20
  "test": "jest"
21
+ },
22
+ "eslintConfig": {
23
+ "extends": "eslint:recommended",
24
+ "parserOptions": {
25
+ "ecmaVersion": 2020
26
+ },
27
+ "env": {
28
+ "node": true,
29
+ "es6": true
30
+ },
31
+ "rules": {
32
+ "no-unused-vars": "off",
33
+ "no-case-declarations": "off",
34
+ "no-empty": "warn",
35
+ "no-fallthrough": "warn"
36
+ }
21
37
  }
22
38
  }
package/validate.js ADDED
@@ -0,0 +1,71 @@
1
+ const View = require("@saltcorn/data/models/view");
2
+ const Table = require("@saltcorn/data/models/table");
3
+
4
+ module.exports = {
5
+ configFields: async ({ table }) => {
6
+ const views = await View.find({
7
+ viewtemplate: "Available Resources Feed",
8
+ table_id: table.id,
9
+ });
10
+ return [
11
+ {
12
+ name: "feedview",
13
+ label: "View",
14
+ sublabel: `A view on table ${table.name} with pattern: Available Resources Feed`,
15
+ type: "String",
16
+ required: true,
17
+ attributes: {
18
+ options: views.map((f) => f.name),
19
+ },
20
+ },
21
+ ];
22
+ },
23
+ requireRow: true,
24
+ run: async ({ table, req, row, configuration: { feedview } }) => {
25
+ const view = View.findOne({ name: feedview });
26
+ const {
27
+ reservable_entity_key,
28
+ valid_field,
29
+ slot_count_field,
30
+ slots_available_field,
31
+ show_view,
32
+ start_field,
33
+ end_field,
34
+ } = view.configuration;
35
+ //get all relevant reservations
36
+
37
+ const ress = await table.getRows({
38
+ [reservable_entity_key]: row[reservable_entity_key],
39
+ [start_field]: { lt: row[end_field], equal: true, day_only: true },
40
+ [end_field]: { gt: row[start_field], equal: true, day_only: true },
41
+ ...(valid_field ? { [valid_field]: true } : {}),
42
+ });
43
+
44
+ //get entity
45
+ const refield = table.getField(reservable_entity_key);
46
+ const retable = Table.findOne(refield.reftable_name);
47
+ const entity = await retable.getRow({
48
+ [retable.pk_name]: row[reservable_entity_key],
49
+ });
50
+ //check that for every day, there is availablity
51
+ const from = new Date(row[start_field]);
52
+ const to = new Date(row[end_field]);
53
+ let maxAvailable = entity[slots_available_field];
54
+ // loop for every day
55
+ for (let day = from; day <= to; day.setDate(day.getDate() + 1)) {
56
+ // your day is here
57
+ const active = ress.filter(
58
+ (r) => r[start_field] <= day && r[end_field] >= day
59
+ );
60
+ const taken = active
61
+ .map((r) => r[slot_count_field])
62
+ .reduce((a, b) => a + b, 0);
63
+ maxAvailable = Math.min(
64
+ maxAvailable,
65
+ entity[slots_available_field] - taken
66
+ );
67
+ }
68
+ if (maxAvailable < row[slot_count_field])
69
+ return { error: `Only ${maxAvailable} are available` };
70
+ },
71
+ };