@panneau/core 4.0.78 → 4.1.1

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/dist/index.js CHANGED
@@ -1,13 +1,12 @@
1
+ import isObject from 'lodash-es/isObject';
1
2
  import { flattenComponents, getComponentFromName } from '@panneau/core/utils';
2
- import { isObject } from 'lodash';
3
3
  import isArray from 'lodash-es/isArray';
4
4
  import uniqBy from 'lodash-es/uniqBy';
5
5
  import EventEmitter from 'wolfy87-eventemitter';
6
- import isObject$1 from 'lodash-es/isObject';
7
- import uniq from 'lodash-es/uniq';
8
6
  import { Tracking as Tracking$1 } from '@folklore/tracking';
9
7
 
10
8
  class ComponentsManager {
9
+ // Nested components maps are flattened with their key as namespace
11
10
  constructor(components = {}) {
12
11
  this.components = flattenComponents(components);
13
12
  }
@@ -175,7 +174,7 @@ class FontsParser {
175
174
  theme = null,
176
175
  components = []
177
176
  } = story || {};
178
- const fonts = uniq(components.reduce((currentFonts, screen) => {
177
+ const fonts = uniqBy(components.reduce((currentFonts, screen) => {
179
178
  const {
180
179
  type
181
180
  } = screen;
@@ -196,7 +195,7 @@ class FontsParser {
196
195
  return fonts.length > 0 || themeFonts.length > 0 ? {
197
196
  ...story,
198
197
  theme: newTheme,
199
- fonts: uniq([...themeFonts, ...fonts], 'name')
198
+ fonts: uniqBy([...themeFonts, ...fonts], 'name')
200
199
  } : story;
201
200
  }
202
201
  return fonts.length > 0 ? {
@@ -244,9 +243,9 @@ class FontsParser {
244
243
  const value = data[key];
245
244
  let font = null;
246
245
  let subFonts = null;
247
- if (patternMatch && isObject$1(value) && FontsParser.valueIsFont(value)) {
246
+ if (patternMatch && isObject(value) && FontsParser.valueIsFont(value)) {
248
247
  font = value;
249
- } else if (isObject$1(value) || isArray(value)) {
248
+ } else if (isObject(value) || isArray(value)) {
250
249
  subFonts = FontsParser.extractFontsWithPaths(value, patterns, path);
251
250
  }
252
251
  return subFonts !== null || font !== null ? [...currentFonts, ...(subFonts || []), ...(font !== null ? [font] : [])] : currentFonts;
@@ -259,7 +258,7 @@ class FormsManager extends DefinitionsManager {}
259
258
  class MediasParser {
260
259
  constructor({
261
260
  fieldsManager,
262
- screensManager
261
+ screensManager = null
263
262
  }) {
264
263
  this.fieldsManager = fieldsManager;
265
264
  this.screensManager = screensManager;
@@ -286,7 +285,7 @@ class MediasParser {
286
285
  } = screen;
287
286
  const {
288
287
  fields = []
289
- } = this.screensManager.getDefinition(type) || {};
288
+ } = this.screensManager?.getDefinition(type) || {};
290
289
  const fieldsPattern = this.getMediaFieldsPattern(fields);
291
290
  const {
292
291
  data: newScreen,
@@ -346,7 +345,7 @@ class MediasParser {
346
345
  } = screen;
347
346
  const {
348
347
  fields = []
349
- } = this.screensManager.getDefinition(type) || {};
348
+ } = this.screensManager?.getDefinition(type) || {};
350
349
  const fieldsPattern = this.getMediaFieldsPattern(fields);
351
350
  return MediasParser.replacePathsWithMedias(screen, medias, fieldsPattern);
352
351
  }) : components;
@@ -405,9 +404,9 @@ class MediasParser {
405
404
  const value = data[key];
406
405
  let newValue;
407
406
  if (patternMatch) {
408
- newValue = isObject$1(value) ? value : medias[value] || null;
407
+ newValue = isObject(value) ? value : medias[value] || null;
409
408
  } else {
410
- newValue = isObject$1(value) || isArray(value) ? MediasParser.replacePathsWithMedias(value, medias, patterns, path) : value;
409
+ newValue = isObject(value) || isArray(value) ? MediasParser.replacePathsWithMedias(value, medias, patterns, path) : value;
411
410
  }
412
411
  return dataIsArray ? [...newData, newValue] : {
413
412
  ...newData,
@@ -433,11 +432,11 @@ class MediasParser {
433
432
  let newValue;
434
433
  let media = null;
435
434
  let subMedias = null;
436
- if (patternMatch && isObject$1(value)) {
435
+ if (patternMatch && isObject(value)) {
437
436
  const mediaPath = MediasParser.getMediaPath(value);
438
437
  newValue = mediaPath !== null ? mediaPath : value;
439
438
  media = mediaPath !== null ? value : null;
440
- } else if (isObject$1(value) || isArray(value)) {
439
+ } else if (isObject(value) || isArray(value)) {
441
440
  const subReturn = MediasParser.replaceMediasWithPaths(value, patterns, medias, path);
442
441
  newValue = subReturn.data;
443
442
  subMedias = subReturn.medias;
@@ -459,7 +458,7 @@ class MediasParser {
459
458
  }
460
459
  };
461
460
  }, {
462
- data: null,
461
+ data: dataIsArray ? [] : {},
463
462
  medias
464
463
  });
465
464
  }
@@ -467,16 +466,6 @@ class MediasParser {
467
466
 
468
467
  /* eslint-disable no-console */
469
468
  class Tracking extends Tracking$1 {
470
- constructor(opts = {}) {
471
- super(opts);
472
- const {
473
- variables = null
474
- } = this.options;
475
- this.variables = null;
476
- if (variables !== null) {
477
- this.setVariables(variables);
478
- }
479
- }
480
469
  setVariables(variables) {
481
470
  this.variables = variables;
482
471
  if (variables !== null) {
@@ -499,10 +488,15 @@ class Tracking extends Tracking$1 {
499
488
  };
500
489
  this.push(data);
501
490
  }
502
- trackEvent(category, action, label, {
503
- value = null,
504
- ...opts
505
- } = {}) {
491
+
492
+ // The options can also be a value, to stay compatible with the base trackEvent signature
493
+ trackEvent(category, action, label, options = {}) {
494
+ const {
495
+ value = null,
496
+ ...opts
497
+ } = isObject(options) ? options : {
498
+ value: options
499
+ };
506
500
  const data = {
507
501
  ...opts,
508
502
  event: 'eventInteraction',
package/dist/utils.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { kebabCase as dashCase, pascalCase, snakeCase } from 'change-case';
2
- import { ElementType, ReactNode, ReactElement, ForwardedRef } from 'react';
3
- import { MessageDescriptor } from 'react-intl';
2
+ import { ElementType, ComponentProps, ReactNode, MouseEvent, ReactElement, ForwardedRef } from 'react';
3
+ import { FormattedMessage, MessageDescriptor } from 'react-intl';
4
4
 
5
5
  interface CheckClickableOptions {
6
6
  maxParentDistance?: number;
@@ -26,11 +26,6 @@ declare function createUseEvent(eventsManager: EventsManager | null): (event: st
26
26
  /*! clipboard-copy. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
27
27
  declare function copyToClipboard(text: string): Promise<void>;
28
28
 
29
- type ComponentMap = Record<string, ElementType | Record<string, unknown>>;
30
- declare function flattenComponents(components: ComponentMap | null, prefix?: string | null): Record<string, ElementType> | null;
31
-
32
- declare function formatDuration(timeInSeconds: number | string): string;
33
-
34
29
  /**
35
30
  * Core UI Types
36
31
  */
@@ -39,10 +34,40 @@ declare function formatDuration(timeInSeconds: number | string): string;
39
34
  * Internationalization message definition
40
35
  */
41
36
  type Message = MessageDescriptor;
37
+ /**
38
+ * Values that can be interpolated in an internationalization message
39
+ * (the `values` prop of FormattedMessage)
40
+ */
41
+ type MessageValues = NonNullable<ComponentProps<typeof FormattedMessage>['values']>;
42
+ type MessageValue = MessageValues[string];
42
43
  /**
43
44
  * Label can be either a message object or any React node
44
45
  */
45
46
  type Label = Message | ReactNode;
47
+ /**
48
+ * Map of components, can be nested by namespace (ex: { fields: { text: TextField } })
49
+ */
50
+ interface ComponentsMap {
51
+ [name: string]: ElementType | ComponentsMap;
52
+ }
53
+ /**
54
+ * Menu item definition
55
+ */
56
+ interface MenuItem {
57
+ id?: number | string;
58
+ label?: Label;
59
+ url?: string;
60
+ href?: string | null;
61
+ external?: boolean;
62
+ active?: boolean;
63
+ type?: string;
64
+ component?: string;
65
+ className?: string | null;
66
+ linkClassName?: string | null;
67
+ onClick?: ((e: MouseEvent) => void) | null;
68
+ items?: MenuItem[] | null;
69
+ dropdown?: MenuItem[] | null;
70
+ }
46
71
  /**
47
72
  * Form control size
48
73
  */
@@ -95,6 +120,8 @@ interface Field {
95
120
  size?: ControlSize;
96
121
  [key: string]: unknown;
97
122
  }
123
+ interface FieldDefinition$1 extends Omit<Field, 'component'>, Definition {
124
+ }
98
125
  /**
99
126
  * Form definition
100
127
  */
@@ -120,6 +147,7 @@ interface FormDefinition extends Form, Definition {
120
147
  type TableColumn = string | Column;
121
148
  interface Column extends Record<string, unknown> {
122
149
  id?: string | null;
150
+ name?: string | null;
123
151
  label?: unknown;
124
152
  path?: string;
125
153
  valueKey?: string;
@@ -145,7 +173,7 @@ type ActionDefinition = string | Action;
145
173
  interface ResourceIntlValues {
146
174
  a_singular?: string;
147
175
  a_plural?: string;
148
- [key: string]: unknown;
176
+ [key: string]: MessageValue;
149
177
  }
150
178
  /**
151
179
  * Internationalization configuration
@@ -164,6 +192,11 @@ interface ResourceIndex {
164
192
  fields?: Field[];
165
193
  [key: string]: unknown;
166
194
  }
195
+ /**
196
+ * Overrides of the built-in resource pages, ex: `{ show: { component: 'my-page' } }`.
197
+ * Used on a resource (`resource.pages`) and on the panneau definition (`definition.pages`),
198
+ * the resource taking precedence.
199
+ */
167
200
  interface ResourcePages {
168
201
  index?: PageDefinition;
169
202
  show?: PageDefinition;
@@ -171,15 +204,49 @@ interface ResourcePages {
171
204
  edit?: PageDefinition;
172
205
  delete?: PageDefinition;
173
206
  duplicate?: PageDefinition;
207
+ /** @deprecated Legacy resource keys, use `index`, `show`, `create`, etc. */
208
+ resourceIndexPage?: PageDefinition;
209
+ resourceShowPage?: PageDefinition;
210
+ resourceCreatePage?: PageDefinition;
211
+ resourceEditPage?: PageDefinition;
212
+ resourceDeletePage?: PageDefinition;
213
+ resourceDuplicatePage?: PageDefinition;
214
+ /** @deprecated Legacy panneau keys, use `index`, `show`, `create`, etc. */
215
+ indexPage?: PageDefinition;
216
+ showPage?: PageDefinition;
217
+ createPage?: PageDefinition;
218
+ editPage?: PageDefinition;
219
+ deletePage?: PageDefinition;
220
+ duplicatePage?: PageDefinition;
174
221
  [key: string]: PageDefinition | undefined;
175
222
  }
223
+ /**
224
+ * Form definition of a resource (the id is optional, the form is identified by its key)
225
+ */
226
+ interface ResourceForm extends Form {
227
+ id?: string;
228
+ component?: string | ElementType;
229
+ }
176
230
  interface ResourceForms {
177
- default: FormDefinition | null;
178
- create: FormDefinition | null;
179
- edit: FormDefinition | null;
180
- delete: FormDefinition | null;
181
- modal: FormDefinition | null;
182
- [key: string]: FormDefinition | null;
231
+ default?: ResourceForm | null;
232
+ create?: ResourceForm | null;
233
+ edit?: ResourceForm | null;
234
+ delete?: ResourceForm | null;
235
+ duplicate?: ResourceForm | null;
236
+ modal?: ResourceForm | null;
237
+ [key: string]: ResourceForm | null | undefined;
238
+ }
239
+ /**
240
+ * Resource type (sub-type of a resource, ex: page types)
241
+ */
242
+ interface ResourceType {
243
+ id?: string;
244
+ name?: string;
245
+ fields?: Field[];
246
+ settings?: {
247
+ canCreate?: boolean;
248
+ [key: string]: unknown;
249
+ };
183
250
  }
184
251
  interface Resource {
185
252
  id: string;
@@ -191,6 +258,7 @@ interface Resource {
191
258
  settings?: Record<string, unknown>;
192
259
  extraRoutes?: RouteDefinition[];
193
260
  pages?: ResourcePages;
261
+ types?: ResourceType[];
194
262
  }
195
263
  /**
196
264
  * Filter definition
@@ -201,6 +269,97 @@ interface Filter {
201
269
  [key: string]: unknown;
202
270
  }
203
271
 
272
+ /**
273
+ * Panneau Definition Types
274
+ * TypeScript interfaces for main Panneau configuration and definitions
275
+ */
276
+
277
+ /**
278
+ * Panneau route configuration
279
+ */
280
+ interface Routes {
281
+ 'resources.index'?: RouteDefinition | string;
282
+ 'resources.create'?: RouteDefinition | string;
283
+ 'resources.store'?: RouteDefinition | string;
284
+ 'resources.show'?: RouteDefinition | string;
285
+ 'resources.edit'?: RouteDefinition | string;
286
+ 'resources.update'?: RouteDefinition | string;
287
+ 'resources.delete'?: RouteDefinition | string;
288
+ 'resources.destroy'?: RouteDefinition | string;
289
+ 'resources.duplicate'?: RouteDefinition | string;
290
+ 'resources.clone'?: RouteDefinition | string;
291
+ 'resources.restore'?: RouteDefinition | string;
292
+ [key: string]: RouteDefinition | string;
293
+ }
294
+ interface PanneauTheme {
295
+ colorScheme?: string | null;
296
+ [key: string]: unknown;
297
+ }
298
+ interface PanneauComponents {
299
+ [key: string]: string | {
300
+ component: string;
301
+ [key: string]: unknown;
302
+ };
303
+ }
304
+ interface PanneauIntlValues {
305
+ [key: string]: string;
306
+ }
307
+ interface PanneauIntl {
308
+ locale?: string;
309
+ locales?: string[];
310
+ messages?: Record<string, string>;
311
+ /** @deprecated Not used: set the values of each resource in `resources[].intl.values` */
312
+ values?: PanneauIntlValues;
313
+ }
314
+ interface PanneauPages extends ResourcePages {
315
+ home?: PageDefinition;
316
+ login?: PageDefinition;
317
+ account?: PageDefinition;
318
+ error?: PageDefinition;
319
+ }
320
+ /**
321
+ * Menu item in a Panneau menu: a menu item definition or a special item id
322
+ * (ex: 'resources', 'account', 'separator')
323
+ */
324
+ type PanneauMenuItem = MenuItem | string;
325
+ interface PanneauMenus {
326
+ main?: PanneauMenuItem[] | null;
327
+ guest?: PanneauMenuItem[] | null;
328
+ [key: string]: PanneauMenuItem[] | null | undefined;
329
+ }
330
+ /**
331
+ * Links shown on the login page: `true` for the default url, or the url of the page
332
+ */
333
+ interface PanneauAuth {
334
+ /** "Forgot your password?" link (default url: /forgot-password) */
335
+ forgotPassword?: boolean | string;
336
+ /** "Create account" link (default url: /register) */
337
+ register?: boolean | string;
338
+ [key: string]: unknown;
339
+ }
340
+ /**
341
+ * Panneau definition (main configuration)
342
+ */
343
+ interface PanneauDefinition {
344
+ name?: string;
345
+ resources?: Resource[];
346
+ routes?: Routes;
347
+ pages?: PanneauPages;
348
+ intl?: PanneauIntl;
349
+ theme?: PanneauTheme;
350
+ /** @deprecated Not used: give the custom components to the `components` prop of the container */
351
+ components?: PanneauComponents;
352
+ settings?: Record<string, unknown>;
353
+ forms?: FormDefinition[];
354
+ fields?: FieldDefinition$1[];
355
+ menus?: PanneauMenus;
356
+ auth?: PanneauAuth;
357
+ }
358
+
359
+ declare function flattenComponents(components: ComponentsMap | null, prefix?: string | null): Record<string, ElementType> | null;
360
+
361
+ declare function formatDuration(timeInSeconds: number | string): string;
362
+
204
363
  declare function getColumnsWithFields(resource: Resource, columns: TableColumn[] | null): Column[];
205
364
 
206
365
  interface ComponentResult {
@@ -216,6 +375,12 @@ declare function getComponentFromName(name?: string | ElementType | null, compon
216
375
 
217
376
  declare function getDefinitionFromId<T extends Definition = Definition>(id?: string | null, items?: T[] | null, defaultObject?: T | null): T | null;
218
377
 
378
+ /**
379
+ * Returns a message for each key of the definition that is not supported anymore,
380
+ * with the key to use instead. Without it, these keys would be silently ignored.
381
+ */
382
+ declare function getDefinitionWarnings(definition: PanneauDefinition | null): string[];
383
+
219
384
  interface ComponentWithName {
220
385
  displayName?: string | null;
221
386
  name?: string | null;
@@ -239,6 +404,8 @@ interface FieldWithSubFields extends Field {
239
404
  type?: string | null;
240
405
  fields?: Field[] | null;
241
406
  field?: Field | null;
407
+ settings?: Field[] | null;
408
+ itemsField?: Field | null;
242
409
  listItems?: boolean;
243
410
  }
244
411
  declare function getFieldFromPath(path: string | string[], fields: Field[], fieldManager: FieldManager): FieldWithSubFields | null;
@@ -259,6 +426,16 @@ declare function loadPackage<T = unknown>(name: string, loader: () => Promise<T>
259
426
 
260
427
  declare function mergeRefs<T>(...refs: Array<ForwardedRef<T> | null | undefined>): (value: T | null) => void;
261
428
 
429
+ /**
430
+ * Parse a date-only value as a local date, ignoring its timezone.
431
+ *
432
+ * A date stored at midnight represents a calendar day, not an instant: converting it to the
433
+ * browser timezone would show the previous day west of the server timezone (ex: a date saved as
434
+ * 2026-10-01 00:00 UTC is 2026-09-30 20:00 in Montreal). Returns null when the value has a time,
435
+ * so the caller can parse it as an instant.
436
+ */
437
+ declare function parseDateOnly(value: string | null | undefined): Date | null;
438
+
262
439
  declare function toggleSelectedItem(items: Item[] | null, item: Item, { multiple: multipleSelection }?: {
263
440
  multiple?: boolean;
264
441
  }): Item[] | null;
@@ -277,4 +454,4 @@ interface FieldWithValidation extends Field {
277
454
  }
278
455
  declare function validateFields(fields: FieldWithValidation[], value: Record<string, unknown> | null): boolean;
279
456
 
280
- export { checkClickable, convertStyleToString, copyToClipboard, createNullableOnChange, createUseEvent, flattenComponents, formatDuration, getColumnsWithFields, getComponent, getComponentFromName, getDefinitionFromId, getDisplayName, getFieldByName, getFieldFromPath, getFileName, getPathValue, getSelectOptions, isMessage, loadPackage, mergeRefs, selectItems, setValue as setFieldValue, slug, toggleSelectedItem, unselectItems, validateFields };
457
+ export { checkClickable, convertStyleToString, copyToClipboard, createNullableOnChange, createUseEvent, flattenComponents, formatDuration, getColumnsWithFields, getComponent, getComponentFromName, getDefinitionFromId, getDefinitionWarnings, getDisplayName, getFieldByName, getFieldFromPath, getFileName, getPathValue, getSelectOptions, isMessage, loadPackage, mergeRefs, parseDateOnly, selectItems, setValue as setFieldValue, slug, toggleSelectedItem, unselectItems, validateFields };
package/dist/utils.js CHANGED
@@ -1,10 +1,9 @@
1
1
  import isNumber from 'lodash-es/isNumber';
2
- import { snakeCase } from 'snake-case';
3
2
  import isObject from 'lodash-es/isObject';
4
3
  import { useEffect, isValidElement } from 'react';
5
4
  import { isValidElementType } from 'react-is';
6
5
  import isString from 'lodash-es/isString';
7
- import { pascalCase, kebabCase, snakeCase as snakeCase$1 } from 'change-case';
6
+ import { pascalCase, kebabCase, snakeCase } from 'change-case';
8
7
  export { kebabCase as dashCase, pascalCase, snakeCase } from 'change-case';
9
8
  import isFunction from 'lodash-es/isFunction';
10
9
  import isArray from 'lodash-es/isArray';
@@ -44,8 +43,17 @@ function checkClickable(el, options = {}, parentDistance = 1) {
44
43
  return false;
45
44
  }
46
45
 
46
+ // Convert a camelCase style property (ex: backgroundColor) to its CSS kebab-case
47
+ // name (ex: background-color). Custom properties (--foo) are kept as is.
48
+ function toCssProperty(key) {
49
+ if (key.startsWith('--')) {
50
+ return key;
51
+ }
52
+ const property = key.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`);
53
+ return property.startsWith('ms-') ? `-${property}` : property;
54
+ }
47
55
  function convertStyleToString(style) {
48
- return style !== null ? Object.keys(style).map(key => `${snakeCase(key)}:${isNumber(style[key]) ? `${style[key]}px` : style[key]};`).join('\n') : '';
56
+ return style !== null ? Object.keys(style).map(key => `${toCssProperty(key)}:${isNumber(style[key]) ? `${style[key]}px` : style[key]};`).join('\n') : '';
49
57
  }
50
58
 
51
59
  function createNullableOnChange(onChange = null) {
@@ -142,12 +150,15 @@ function flattenComponents(components, prefix = null) {
142
150
  if (components === null) {
143
151
  return null;
144
152
  }
145
- return Object.keys(components).reduce((newMap, key) => isValidElementType(components[key]) ? {
146
- ...newMap,
147
- [prefix !== null ? `${prefix}.${key}` : key]: components[key]
148
- } : {
149
- ...newMap,
150
- ...flattenComponents(components[key], key)
153
+ return Object.keys(components).reduce((newMap, key) => {
154
+ const path = prefix !== null ? `${prefix}.${key}` : key;
155
+ return isValidElementType(components[key]) ? {
156
+ ...newMap,
157
+ [path]: components[key]
158
+ } : {
159
+ ...newMap,
160
+ ...flattenComponents(components[key], path)
161
+ };
151
162
  }, {});
152
163
  }
153
164
 
@@ -251,6 +262,33 @@ function getDefinitionFromId(id = null, items = [], defaultObject = null) {
251
262
  return items.find(i => i.id === pascalId) || items.find(i => i.id === id) || defaultObject;
252
263
  }
253
264
 
265
+ /**
266
+ * Returns a message for each key of the definition that is not supported anymore,
267
+ * with the key to use instead. Without it, these keys would be silently ignored.
268
+ */
269
+ function getDefinitionWarnings(definition) {
270
+ if (!isObject(definition)) {
271
+ return [];
272
+ }
273
+ const {
274
+ components = null,
275
+ intl = null,
276
+ theme = null,
277
+ resources = []
278
+ } = definition;
279
+ const {
280
+ values = null
281
+ } = intl || {};
282
+ return [...(components !== null ? ['`components` is not supported: give the custom components to the `components` prop of the container.'] : []), ...(values !== null ? ['`intl.values` is not supported: set the values of each resource in `resources[].intl.values`.'] : []), ...Object.keys(theme || {}).filter(key => key !== 'colorScheme').map(key => `\`theme.${key}\` is not supported: only \`theme.colorScheme\` is.`), ...(resources || []).reduce((warnings, resource) => {
283
+ const {
284
+ id,
285
+ components: resourceComponents = null,
286
+ columns = null
287
+ } = resource;
288
+ return [...warnings, ...(resourceComponents !== null ? [`\`resources[${id}].components\` is not supported: use \`index.component\`, \`forms.default.component\` and \`pages\`.`] : []), ...(columns !== null ? [`\`resources[${id}].columns\` is not supported: use \`index.columns\`.`] : [])];
289
+ }, [])];
290
+ }
291
+
254
292
  function getDisplayName({
255
293
  displayName = null,
256
294
  name = null
@@ -275,6 +313,9 @@ function getFieldByName(fields, name) {
275
313
  }
276
314
 
277
315
  function getFieldFromPath(path, fields, fieldManager) {
316
+ const initialField = {
317
+ fields
318
+ };
278
319
  return (isArray(path) ? path : [path]).reduce((foundField, key) => {
279
320
  if (foundField === null) {
280
321
  return null;
@@ -298,9 +339,7 @@ function getFieldFromPath(path, fields, fieldManager) {
298
339
  };
299
340
  }
300
341
  return getFieldByName([...(fieldFields || []), ...(subFields || []), ...(settings || [])], key);
301
- }, {
302
- fields
303
- });
342
+ }, initialField);
304
343
  }
305
344
 
306
345
  function getFileName(url = null) {
@@ -359,24 +398,24 @@ function loadPackage(name, loader) {
359
398
  loaders[name].resource = response;
360
399
  events.emit(`loaded:${name}`, response);
361
400
  return response;
401
+ }, error => {
402
+ // Remove the failed loader so a later call can retry
403
+ delete loaders[name];
404
+ throw error;
362
405
  }),
363
406
  loaded: false,
364
407
  resource: null
365
408
  };
366
409
  }
367
- return new Promise(resolve => {
368
- const {
369
- loaded = false,
370
- resource
371
- } = loaders[name] || {};
372
- if (loaded === true) {
373
- resolve(resource);
374
- } else {
375
- events.once(`loaded:${name}`, response => {
376
- resolve(response);
377
- });
378
- }
379
- });
410
+ const {
411
+ loaded = false,
412
+ resource = null,
413
+ loader: currentLoader = null
414
+ } = loaders[name];
415
+ if (loaded === true) {
416
+ return Promise.resolve(resource);
417
+ }
418
+ return currentLoader;
380
419
  }
381
420
 
382
421
  function mergeRefs(...refs) {
@@ -391,6 +430,30 @@ function mergeRefs(...refs) {
391
430
  };
392
431
  }
393
432
 
433
+ // A date without time (2026-10-01), or a date at midnight with or without a timezone
434
+ // (2026-10-01T00:00:00-04:00, 2026-10-01T00:00:00.000000Z, 2026-10-01 00:00:00)
435
+ const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:[T ]00:00(?::00(?:\.0+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
436
+
437
+ /**
438
+ * Parse a date-only value as a local date, ignoring its timezone.
439
+ *
440
+ * A date stored at midnight represents a calendar day, not an instant: converting it to the
441
+ * browser timezone would show the previous day west of the server timezone (ex: a date saved as
442
+ * 2026-10-01 00:00 UTC is 2026-09-30 20:00 in Montreal). Returns null when the value has a time,
443
+ * so the caller can parse it as an instant.
444
+ */
445
+ function parseDateOnly(value) {
446
+ if (typeof value !== 'string') {
447
+ return null;
448
+ }
449
+ const match = DATE_ONLY_PATTERN.exec(value.trim());
450
+ if (match === null) {
451
+ return null;
452
+ }
453
+ const [, year, month, day] = match;
454
+ return new Date(parseInt(year, 10), parseInt(month, 10) - 1, parseInt(day, 10));
455
+ }
456
+
394
457
  function toggleSelectedItem(items, item, {
395
458
  multiple: multipleSelection = false
396
459
  } = {}) {
@@ -441,8 +504,18 @@ function setValue(value, keyParts, fieldValue) {
441
504
  if (value !== null || fieldValue !== null) {
442
505
  if (isArray) {
443
506
  const index = parseInt(key, 10);
444
- const arrayValue = value;
445
- const newArrayValue = fieldValue !== null ? [...arrayValue.slice(0, index), keyParts.length > 0 ? setValue(arrayValue !== null ? arrayValue[index] || null : null, keyParts, fieldValue) : fieldValue, ...arrayValue.slice(index + 1)] : [...arrayValue.slice(0, index), ...arrayValue.slice(index + 1)];
507
+ const arrayValue = Array.isArray(value) ? value : [];
508
+ let newArrayValue;
509
+ if (fieldValue !== null) {
510
+ newArrayValue = [...arrayValue];
511
+ // Pad with null so an out-of-range index lands at the right position
512
+ while (newArrayValue.length < index) {
513
+ newArrayValue.push(null);
514
+ }
515
+ newArrayValue[index] = keyParts.length > 0 ? setValue(arrayValue[index] || null, keyParts, fieldValue) : fieldValue;
516
+ } else {
517
+ newArrayValue = [...arrayValue.slice(0, index), ...arrayValue.slice(index + 1)];
518
+ }
446
519
  return newArrayValue.length > 0 ? newArrayValue : null;
447
520
  }
448
521
  const objectValue = value;
@@ -459,7 +532,7 @@ function slug(str, separator = null) {
459
532
  if (separator === '-') {
460
533
  toSlug = kebabCase(str);
461
534
  } else {
462
- toSlug = snakeCase$1(str);
535
+ toSlug = snakeCase(str);
463
536
  }
464
537
  return slugify(toSlug, {
465
538
  lower: true
@@ -472,11 +545,13 @@ function validateFields(fields, value) {
472
545
  if (field.type === 'fields' && field.fields) {
473
546
  return validateFields(field.fields, value);
474
547
  }
475
- const val = value && field.name && value[field.name] ? value[field.name] : false;
476
- return !(field.required && !val);
548
+ const val = value && field.name ? value[field.name] : null;
549
+ // Only null, undefined and empty strings are considered missing (0 and false are valid)
550
+ const isMissing = val === null || typeof val === 'undefined' || val === '';
551
+ return !(field.required && isMissing);
477
552
  }
478
553
  return acc;
479
554
  }, true);
480
555
  }
481
556
 
482
- export { checkClickable, convertStyleToString, copyToClipboard, createNullableOnChange, createUseEvent, flattenComponents, formatDuration, getColumnsWithFields, getComponent, getComponentFromName, getDefinitionFromId, getDisplayName, getFieldByName, getFieldFromPath, getFileName, getPathValue, getSelectOptions, isMessage, loadPackage, mergeRefs, selectItems, setValue as setFieldValue, slug, toggleSelectedItem, unselectItems, validateFields };
557
+ export { checkClickable, convertStyleToString, copyToClipboard, createNullableOnChange, createUseEvent, flattenComponents, formatDuration, getColumnsWithFields, getComponent, getComponentFromName, getDefinitionFromId, getDefinitionWarnings, getDisplayName, getFieldByName, getFieldFromPath, getFileName, getPathValue, getSelectOptions, isMessage, loadPackage, mergeRefs, parseDateOnly, selectItems, setValue as setFieldValue, slug, toggleSelectedItem, unselectItems, validateFields };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panneau/core",
3
- "version": "4.0.78",
3
+ "version": "4.1.1",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "javascript"
@@ -75,7 +75,7 @@
75
75
  "debug": "^4.3.4",
76
76
  "flat": "^6.0.1",
77
77
  "hoist-non-react-statics": "^3.3.2",
78
- "lodash": "^4.17.21",
78
+ "lodash-es": "^4.17.21",
79
79
  "react-intl": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^10.0.0",
80
80
  "react-is": "^19.2.5",
81
81
  "slugify": "^1.5.0",
@@ -96,5 +96,5 @@
96
96
  "publishConfig": {
97
97
  "access": "public"
98
98
  },
99
- "gitHead": "669f20a39ca9545acbe0f2279c6d1af23e43ae6e"
99
+ "gitHead": "28b394278cffa0c927989873dd4b3f184635ceff"
100
100
  }