@visns-studio/visns-components 6.0.4 → 6.1.0

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.
Files changed (26) hide show
  1. package/package.json +1 -1
  2. package/src/components/DataGrid.jsx +37 -12
  3. package/src/components/Navigation.jsx +59 -3
  4. package/src/components/auth/Login.jsx +203 -175
  5. package/src/components/columns/ColumnRenderers.jsx +34 -0
  6. package/src/components/generic/GenericDashboard.jsx +181 -1
  7. package/src/components/generic/GenericFormBuilder.jsx +735 -37
  8. package/src/components/generic/GenericIndex.jsx +15 -5
  9. package/src/components/generic/OutstandingRuleEditor.jsx +313 -0
  10. package/src/components/styles/DataGrid.module.scss +38 -22
  11. package/src/components/styles/Form.module.scss +32 -33
  12. package/src/components/styles/GenericDashboard.module.scss +155 -0
  13. package/src/components/styles/GenericDetail.module.scss +28 -30
  14. package/src/components/styles/GenericDynamic.module.scss +7 -21
  15. package/src/components/styles/GenericEditableTable.module.scss +5 -22
  16. package/src/components/styles/GenericFormBuilder.module.scss +605 -30
  17. package/src/components/styles/GenericIndex.module.scss +17 -32
  18. package/src/components/styles/GenericMain.module.scss +18 -6
  19. package/src/components/styles/GenericQuote.module.scss +7 -22
  20. package/src/components/styles/GenericReport.module.scss +5 -37
  21. package/src/components/styles/Login.module.scss +415 -239
  22. package/src/components/styles/Navigation.module.scss +124 -35
  23. package/src/components/styles/Profile.module.scss +5 -26
  24. package/src/components/styles/QuickAction.module.scss +34 -29
  25. package/src/components/styles/_controls.scss +139 -0
  26. package/src/components/styles/global.css +54 -15
package/package.json CHANGED
@@ -91,7 +91,7 @@
91
91
  "react-dom": "^17.0.0 || ^18.0.0"
92
92
  },
93
93
  "name": "@visns-studio/visns-components",
94
- "version": "6.0.4",
94
+ "version": "6.1.0",
95
95
  "description": "Various packages to assist in the development of our Custom Applications.",
96
96
  "main": "src/index.js",
97
97
  "files": [
@@ -236,6 +236,34 @@ const loadData = async (
236
236
  }
237
237
  };
238
238
 
239
+ /**
240
+ * How tall the grid should be.
241
+ *
242
+ * It used to claim 70% of the viewport (or 550px) for any result set larger
243
+ * than eight rows, whatever the actual row count — so 18 rows on a tall
244
+ * monitor left several hundred pixels of empty white below the last one.
245
+ *
246
+ * Height now follows the content and only falls back to a viewport share when
247
+ * the rows genuinely overflow, which is the point at which a tall scroller is
248
+ * useful rather than just empty.
249
+ */
250
+ const ROW_HEIGHT = 34;
251
+ const GRID_CHROME = ROW_HEIGHT * 2 + 60; // header + filter row + footer
252
+
253
+ function fitToRows(dataCount, windowHeight) {
254
+ const roomy = Math.max(windowHeight * 0.7, 550);
255
+
256
+ if (!dataCount || dataCount <= 0) {
257
+ // Nothing to show: enough for the empty state, not a blank screen.
258
+ return 300;
259
+ }
260
+
261
+ const needed = dataCount * ROW_HEIGHT + GRID_CHROME;
262
+
263
+ // Never taller than the rows require, never so short it feels cramped.
264
+ return Math.min(roomy, Math.max(needed, 260));
265
+ }
266
+
239
267
  const DataGrid = forwardRef(
240
268
  (
241
269
  {
@@ -1277,6 +1305,11 @@ const DataGrid = forwardRef(
1277
1305
 
1278
1306
  const handleSettingClick = (s, d) => {
1279
1307
  switch (s.id) {
1308
+ case 'cloudDownload':
1309
+ // File download: open the URL directly and let the
1310
+ // browser handle the response's content disposition.
1311
+ window.open(`${s.url}/${d[s.key]}`, '_blank');
1312
+ break;
1280
1313
  case 'activate':
1281
1314
  case 'arrowCycle':
1282
1315
  case 'cloudUpload':
@@ -3203,6 +3236,8 @@ const DataGrid = forwardRef(
3203
3236
  return getIconComponent(RefreshCw);
3204
3237
  case 'cloudUpload':
3205
3238
  return getIconComponent(Upload);
3239
+ case 'cloudDownload':
3240
+ return getIconComponent(DownloadIcon);
3206
3241
  case 'oauth2':
3207
3242
  return getIconComponent(Lock);
3208
3243
  case 'clone':
@@ -4380,13 +4415,7 @@ const DataGrid = forwardRef(
4380
4415
  minHeight:
4381
4416
  gridHeight && gridHeight > 0
4382
4417
  ? gridHeight
4383
- : dataCount > 5
4384
- ? dataCount > 8
4385
- ? windowHeight * 0.7 > 550
4386
- ? windowHeight * 0.7
4387
- : 550
4388
- : 450
4389
- : 400,
4418
+ : fitToRows(dataCount, windowHeight),
4390
4419
  boxShadow: 'none',
4391
4420
  ...(hoverColor ? { '--hover-color': hoverColor } : {}),
4392
4421
  });
@@ -4397,11 +4426,7 @@ const DataGrid = forwardRef(
4397
4426
  useEffect(() => {
4398
4427
  const getMinimumHeight = () => {
4399
4428
  if (gridHeight > 0) return gridHeight;
4400
- if (dataCount > 0) {
4401
- if (dataCount <= 5) return 400;
4402
- if (dataCount <= 8) return 450;
4403
- }
4404
- return Math.max(window.innerHeight * 0.7, 550);
4429
+ return fitToRows(dataCount, window.innerHeight);
4405
4430
  };
4406
4431
 
4407
4432
  const minHeight = getMinimumHeight();
@@ -361,6 +361,7 @@ function Navigation({
361
361
  <ul className={styles.navDropdown}>
362
362
  {n.children.map(
363
363
  (child, childKey) =>
364
+ child.hidden !== true &&
364
365
  child.permission === true && (
365
366
  <li key={`nav-child-${childKey}`}>
366
367
  <Link
@@ -422,11 +423,57 @@ function Navigation({
422
423
  return [];
423
424
  };
424
425
 
426
+ /**
427
+ * Feature switches, if the consuming app publishes any.
428
+ *
429
+ * A platform owner can retire a half-finished feature from the menu
430
+ * without a deploy, so the profile response may carry a map of feature
431
+ * ids that are switched off. Apps that publish nothing get `null` and
432
+ * behave exactly as before — this filter is additive, and every unknown
433
+ * id stays visible, so a stale or missing map can never empty a menu.
434
+ */
435
+ const getFeatureFlags = () => {
436
+ const flags = userProfile?.feature_visibility;
437
+
438
+ if (!flags || typeof flags !== 'object') {
439
+ return null;
440
+ }
441
+
442
+ // The map is keyed by app; this component only ever draws the console.
443
+ const appFlags = flags.backend;
444
+
445
+ return appFlags && typeof appFlags === 'object' ? appFlags : null;
446
+ };
447
+
425
448
  useEffect(() => {
426
449
  const permissions = getUserPermissions();
450
+ const featureFlags = getFeatureFlags();
451
+
452
+ // Carried as its own flag rather than folded into `permission`: an
453
+ // item with an empty permissionKey renders whatever its permission
454
+ // says, and this state has to be reversible — switching a feature
455
+ // back on must restore the menu item without a page reload, which
456
+ // dropping it from the list would not.
457
+ const isSwitchedOff = (nav) =>
458
+ featureFlags !== null &&
459
+ Boolean(nav.id) &&
460
+ featureFlags[nav.id] === false;
427
461
 
428
462
  setNavData((prevNavData) => {
429
463
  const updatePermissions = (nav) => {
464
+ const hidden = isSwitchedOff(nav);
465
+
466
+ // Reserved for the platform owner — a privilege that sits
467
+ // above the roles an administrator can hand out, so it is
468
+ // never expressed as a permission key.
469
+ if (nav.superAdminOnly === true) {
470
+ return {
471
+ ...nav,
472
+ hidden,
473
+ permission: Boolean(userProfile?.is_super_admin),
474
+ };
475
+ }
476
+
430
477
  if (nav.children && nav.children.length > 0) {
431
478
  const updatedChildren = nav.children
432
479
  .map(updatePermissions)
@@ -439,12 +486,14 @@ function Navigation({
439
486
  );
440
487
  return {
441
488
  ...nav,
489
+ hidden,
442
490
  permission: matchingPermission,
443
491
  children: updatedChildren,
444
492
  };
445
493
  } else {
446
494
  return {
447
495
  ...nav,
496
+ hidden,
448
497
  permission: childPermission,
449
498
  children: updatedChildren,
450
499
  };
@@ -457,12 +506,14 @@ function Navigation({
457
506
  );
458
507
  return {
459
508
  ...nav,
509
+ hidden,
460
510
  permission: matchingPermission,
461
511
  };
462
512
  }
463
513
 
464
514
  return {
465
515
  ...nav,
516
+ hidden,
466
517
  permission: true,
467
518
  };
468
519
  };
@@ -620,8 +671,9 @@ function Navigation({
620
671
  <nav className={styles.navwrap} ref={navWrapRef}>
621
672
  <ul className={appNavClasses}>
622
673
  {navData.navigations.map((nav, navKey) =>
623
- nav.permission === true ||
624
- nav.permissionKey === '' ? (
674
+ nav.hidden !== true &&
675
+ (nav.permission === true ||
676
+ nav.permissionKey === '') ? (
625
677
  <React.Fragment key={`nav-item-${navKey}`}>
626
678
  {renderNav(nav)}
627
679
  </React.Fragment>
@@ -671,6 +723,7 @@ function Navigation({
671
723
  >
672
724
  <ul>
673
725
  {navData.settings.map((setting, settingKey) =>
726
+ setting.hidden !== true &&
674
727
  setting.permission === true ? (
675
728
  <React.Fragment
676
729
  key={`setting-${settingKey}`}
@@ -711,7 +764,9 @@ function Navigation({
711
764
  <div className={styles.navwrap} ref={navWrapRef}>
712
765
  <ul className={appNavClasses}>
713
766
  {navData.navigations.map((nav, navKey) =>
714
- nav.permission === true || nav.permissionKey === '' ? (
767
+ nav.hidden !== true &&
768
+ (nav.permission === true ||
769
+ nav.permissionKey === '') ? (
715
770
  <React.Fragment key={`nav-item-${navKey}`}>
716
771
  {renderNav(nav)}
717
772
  </React.Fragment>
@@ -729,6 +784,7 @@ function Navigation({
729
784
  >
730
785
  <ul>
731
786
  {navData.settings.map((setting, settingKey) =>
787
+ setting.hidden !== true &&
732
788
  setting.permission === true ? (
733
789
  <React.Fragment key={`setting-${settingKey}`}>
734
790
  {renderSetting(setting)}
@@ -1,19 +1,41 @@
1
1
  import '../styles/global.css';
2
2
 
3
- import React, { useState } from 'react';
3
+ import React, { useEffect, useRef, useState } from 'react';
4
4
  import { Link, useLocation, useNavigate } from 'react-router-dom';
5
- import Reveal from '../utils/Reveal';
6
5
  import { toast } from 'react-toastify';
7
- import { User, Lock } from 'lucide-react';
6
+ import { Eye, EyeOff } from 'lucide-react';
8
7
 
9
8
  import CustomFetch from '../Fetch';
10
9
 
11
10
  import styles from '../styles/Login.module.scss';
12
11
 
13
- const Login = ({ logo, providers, setSystemAuth, setUserProfile, config = {} }) => {
12
+ const Login = ({
13
+ logo,
14
+ loginBg,
15
+ providers,
16
+ setSystemAuth,
17
+ setUserProfile,
18
+ config = {},
19
+ }) => {
14
20
  const location = useLocation();
15
21
  const navigate = useNavigate();
16
22
  const [isLoading, setIsLoading] = useState(false);
23
+ const [showPassword, setShowPassword] = useState(false);
24
+
25
+ // Collapsed behind a button, since Microsoft is how nearly everyone signs
26
+ // in. Open from the start when there is no SSO provider configured —
27
+ // otherwise the only way in would be hidden behind a disclosure.
28
+ const ssoAvailable = Array.isArray(providers) && providers.length > 0;
29
+ const [showEmail, setShowEmail] = useState(!ssoAvailable);
30
+ const emailRef = useRef(null);
31
+
32
+ // Opening the form should put the cursor where typing starts, rather than
33
+ // leaving it on the button that opened it.
34
+ useEffect(() => {
35
+ if (showEmail && ssoAvailable) {
36
+ emailRef.current?.focus();
37
+ }
38
+ }, [showEmail, ssoAvailable]);
17
39
 
18
40
  const [auth, setAuth] = useState({
19
41
  email: '',
@@ -32,43 +54,37 @@ const Login = ({ logo, providers, setSystemAuth, setUserProfile, config = {} })
32
54
  return null;
33
55
  }
34
56
 
35
- let content = <div className={styles.divider}>OR</div>;
36
-
37
- if (providers) {
38
- providers.forEach((provider) => {
39
- switch (provider) {
40
- case 'azure':
41
- content = (
42
- <>
43
- {content}
44
- <div
45
- className={`${styles.formItem} ${styles.fwItem} ${styles.lastItem}`}
46
- >
47
- <button
48
- className={styles.ssoButton}
49
- onClick={(e) => {
50
- e.preventDefault();
51
- window.location.href =
52
- '/auth/azure';
53
- }}
54
- >
55
- <img
56
- src={
57
- 'https://d16lktya8ojp5z.cloudfront.net/generic/images/microsoft.png'
58
- }
59
- width="18px"
60
- height="18px"
61
- alt="Microsoft logo"
62
- />
63
- Sign in with Microsoft
64
- </button>
65
- </div>
66
- </>
67
- );
68
- break;
69
- }
70
- });
71
- }
57
+ let content = null;
58
+
59
+ providers.forEach((provider) => {
60
+ switch (provider) {
61
+ case 'azure':
62
+ content = (
63
+ <button
64
+ type="button"
65
+ className={styles.sso}
66
+ onClick={(e) => {
67
+ e.preventDefault();
68
+ window.location.href = '/auth/azure';
69
+ }}
70
+ >
71
+ {/* The Microsoft mark keeps its own light tile.
72
+ Its four squares are drawn for a white ground
73
+ and sit awkwardly straight on navy. */}
74
+ <span className={styles.ssoMark}>
75
+ <img
76
+ src="https://d16lktya8ojp5z.cloudfront.net/generic/images/microsoft.png"
77
+ width="18"
78
+ height="18"
79
+ alt=""
80
+ />
81
+ </span>
82
+ Sign in with Microsoft
83
+ </button>
84
+ );
85
+ break;
86
+ }
87
+ });
72
88
 
73
89
  return content;
74
90
  };
@@ -162,141 +178,153 @@ const Login = ({ logo, providers, setSystemAuth, setUserProfile, config = {} })
162
178
  };
163
179
 
164
180
  return (
165
- <div className={styles.lcontainer}>
166
- <div className={styles.lwrap}>
167
- <div className={styles.logincontainer}>
168
- <form onSubmit={handleSubmit}>
169
- <Reveal effect="fadeInUp">
170
- <div className={styles.login}>
171
- <img
172
- src={logo}
173
- alt="CRM"
174
- className={styles.loginlogo}
175
- />
176
- <h2
177
- style={{
178
- textAlign: 'center',
179
- marginBottom: '1.5rem',
180
- color: '#111827',
181
- fontWeight: '600',
182
- fontSize: '1.5rem',
183
- }}
184
- >
185
- Sign in to your account
186
- </h2>
187
- <div
188
- className={`${styles.formItem} ${styles.fwItem}`}
189
- >
190
- <label className={styles.fi__label}>
191
- <input
192
- type="text"
193
- name="email"
194
- value={auth.email}
195
- onChange={handleChange}
196
- tabIndex="1"
197
- className={authClass.username}
198
- placeholder=" "
199
- />
200
- <span className={styles.fi__span}>
201
- Email
202
- </span>
203
- <small>
204
- <User strokeWidth={2} size={18} />
205
- </small>
206
- </label>
207
- </div>
208
- <div
209
- className={`${styles.formItem} ${styles.fwItem}`}
210
- >
211
- <label className={styles.fi__label}>
212
- <input
213
- type="password"
214
- name="password"
215
- value={auth.password}
216
- onChange={handleChange}
217
- tabIndex="2"
218
- className={authClass.password}
219
- placeholder=" "
220
- />
221
- <span className={styles.fi__span}>
222
- Password
223
- </span>
224
- <small>
225
- <Lock
226
- strokeWidth={2}
227
- size={18}
228
- />
229
- </small>
230
- </label>
231
- </div>
232
- <div
233
- className={`${styles.formItem} ${styles.fwItem}`}
234
- >
235
- <div className={styles.rememberMeContainer}>
236
- <div
237
- style={{
238
- display: 'flex',
239
- alignItems: 'center',
240
- }}
241
- >
242
- <input
243
- type="checkbox"
244
- id="rememberMe"
245
- name="remember"
246
- checked={auth.remember}
247
- onChange={handleChange}
248
- className={
249
- styles.rememberMeCheckbox
250
- }
251
- />
252
- <label
253
- htmlFor="rememberMe"
254
- className={
255
- styles.rememberMeLabel
256
- }
257
- >
258
- Remember me
259
- </label>
260
- </div>
261
- {config.passwordReset !== false && (
262
- <div>
263
- <Link
264
- to="/reset"
265
- style={{
266
- color: 'var(--primary-color, #4f46e5)',
267
- textDecoration: 'none',
268
- fontWeight: '500',
269
- fontSize: '0.875rem',
270
- }}
271
- className={
272
- styles.forgotPasswordLink
273
- }
274
- >
275
- Forgot password?
276
- </Link>
277
- </div>
278
- )}
279
- </div>
280
- </div>
281
- <div
282
- className={`${styles.formItem} ${styles.fwItem} ${styles.lastItem}`}
283
- >
284
- <button
285
- className={styles.btn}
286
- type="submit"
287
- disabled={isLoading}
288
- >
289
- {isLoading
290
- ? 'Signing in...'
291
- : 'Sign in'}
292
- </button>
293
- </div>
294
- {renderSsoButton()}
295
- </div>
296
- </Reveal>
297
- </form>
181
+ <div className={styles.auth}>
182
+ {/* The company's own work, not decoration. It is the reason the
183
+ rest of this system exists, so it carries the brand side and
184
+ the form is left to be a form. */}
185
+ <aside
186
+ className={styles.brand}
187
+ style={
188
+ loginBg ? { backgroundImage: `url(${loginBg})` } : undefined
189
+ }
190
+ aria-hidden="true"
191
+ >
192
+ <div className={styles.brandInk}>
193
+ <p className={styles.eyebrow}>Construction Management</p>
194
+ <p className={styles.brandLine}>Prime Builders</p>
195
+ <p className={styles.brandSub}>
196
+ Job tracking, inspections, site forms and labour
197
+ scheduling — in one place.
198
+ </p>
199
+ </div>
200
+ </aside>
201
+
202
+ <main className={styles.panel}>
203
+ <div className={styles.panelInner}>
204
+ <img src={logo} alt="Prime Builders" className={styles.logo} />
205
+
206
+ <h1 className={styles.title}>Sign in</h1>
207
+ <p className={styles.subtitle}>
208
+ Use your Prime Microsoft account to continue.
209
+ </p>
210
+
211
+ {/* Primary: this is how Prime staff actually sign in. The
212
+ email form stays available underneath for accounts
213
+ without a Microsoft sign-in. */}
214
+ {renderSsoButton()}
215
+
216
+ {ssoAvailable && !showEmail && (
217
+ <button
218
+ type="button"
219
+ className={styles.altToggle}
220
+ onClick={() => setShowEmail(true)}
221
+ aria-expanded="false"
222
+ aria-controls="email-signin"
223
+ >
224
+ Sign in with email instead
225
+ </button>
226
+ )}
227
+
228
+ {showEmail && ssoAvailable && (
229
+ <div className={styles.divider}>
230
+ <span>or sign in with email</span>
231
+ </div>
232
+ )}
233
+
234
+ {/* A grid row animating 0fr -> 1fr, so it opens to whatever
235
+ height the form needs without hardcoding one. */}
236
+ <div
237
+ id="email-signin"
238
+ className={`${styles.collapse} ${
239
+ showEmail ? styles.collapseOpen : ''
240
+ }`}
241
+ >
242
+ <div className={styles.collapseInner}>
243
+ <form onSubmit={handleSubmit} className={styles.form}>
244
+ <div className={styles.field}>
245
+ <input
246
+ id="login-email"
247
+ ref={emailRef}
248
+ type="text"
249
+ name="email"
250
+ value={auth.email}
251
+ onChange={handleChange}
252
+ tabIndex="1"
253
+ className={authClass.username}
254
+ autoComplete="username"
255
+ placeholder=" "
256
+ />
257
+ <label htmlFor="login-email">Email</label>
258
+ </div>
259
+
260
+ <div className={styles.field}>
261
+ <input
262
+ id="login-password"
263
+ type={showPassword ? 'text' : 'password'}
264
+ name="password"
265
+ value={auth.password}
266
+ onChange={handleChange}
267
+ tabIndex="2"
268
+ className={authClass.password}
269
+ autoComplete="current-password"
270
+ placeholder=" "
271
+ />
272
+ <label htmlFor="login-password">Password</label>
273
+ {/* Typing a password blind on a phone in the sun is
274
+ where most failed sign-ins come from. */}
275
+ <button
276
+ type="button"
277
+ className={styles.reveal}
278
+ onClick={() => setShowPassword((on) => !on)}
279
+ aria-label={
280
+ showPassword
281
+ ? 'Hide password'
282
+ : 'Show password'
283
+ }
284
+ tabIndex="-1"
285
+ >
286
+ {showPassword ? (
287
+ <EyeOff size={18} strokeWidth={1.9} />
288
+ ) : (
289
+ <Eye size={18} strokeWidth={1.9} />
290
+ )}
291
+ </button>
292
+ </div>
293
+
294
+ <div className={styles.row}>
295
+ <label className={styles.remember}>
296
+ <input
297
+ type="checkbox"
298
+ name="remember"
299
+ checked={auth.remember}
300
+ onChange={handleChange}
301
+ />
302
+ Remember me
303
+ </label>
304
+
305
+ {config.passwordReset !== false && (
306
+ <Link to="/reset" className={styles.link}>
307
+ Forgot password?
308
+ </Link>
309
+ )}
310
+ </div>
311
+
312
+ <button
313
+ className={styles.submit}
314
+ type="submit"
315
+ disabled={isLoading}
316
+ >
317
+ {isLoading ? 'Signing in…' : 'Sign in with email'}
318
+ </button>
319
+ </form>
320
+ </div>
321
+ </div>
322
+
323
+ <p className={styles.foot}>
324
+ Prime Builders · Perth, Western Australia
325
+ </p>
298
326
  </div>
299
- </div>
327
+ </main>
300
328
  </div>
301
329
  );
302
330
  };