@adobe-commerce/elsie 1.4.1-alpha001 → 1.4.1-alpha003

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.
@@ -1,6 +1,7 @@
1
1
  const path = require('path');
2
2
  const createOrClearDirectory = require('./createOrClearDirectory');
3
3
  const getSchemaRef = require('./getSchemaRef');
4
+ const validate = require('./validate');
4
5
  require('dotenv').config();
5
6
 
6
7
  const generate = require('@graphql-codegen/cli').generate;
@@ -67,5 +68,30 @@ module.exports = async function generateResourceBuilder(yargs) {
67
68
  },
68
69
  });
69
70
  })
70
- .demandCommand(1, 1, 'choose a command: types or mocks');
71
+ .command(
72
+ 'validate',
73
+ 'Validate GraphQL operations',
74
+ async (yargs) => {
75
+ return yargs
76
+ .option('source', {
77
+ alias: 's',
78
+ describe: 'Path to the source code containing GraphQL operations',
79
+ type: 'array',
80
+ string: true,
81
+ demandOption: true,
82
+ })
83
+ .option('endpoints', {
84
+ alias: 'e',
85
+ describe: 'Path to GraphQL endpoints',
86
+ type: 'array',
87
+ string: true,
88
+ demandOption: true,
89
+ });
90
+ },
91
+ async (argv) => {
92
+ const { source, endpoints } = argv;
93
+ await validate(source, endpoints);
94
+ },
95
+ )
96
+ .demandCommand(1, 1, 'choose a command: types, mocks or validate');
71
97
  };
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ const fsPromises = require('node:fs/promises');
3
+ const path = require('node:path');
4
+ const parser = require('@babel/parser');
5
+ const traverse = require('@babel/traverse');
6
+ const { getIntrospectionQuery, buildClientSchema, parse, validate } = require('graphql');
7
+
8
+ async function walk(dir, collected = []) {
9
+ const dirents = await fsPromises.readdir(dir, { withFileTypes: true });
10
+
11
+ for (const d of dirents) {
12
+ const full = path.resolve(dir, d.name);
13
+
14
+ if (d.isDirectory()) {
15
+ // skip node_modules and “hidden” folders such as .git
16
+ if (d.name === 'node_modules' || d.name.startsWith('.')) continue;
17
+ await walk(full, collected);
18
+ } else if (/\.(c?m?js|ts|tsx)$/.test(d.name)) {
19
+ collected.push(full);
20
+ }
21
+ }
22
+ return collected;
23
+ }
24
+
25
+ function extractConstants(code) {
26
+ const ast = parser.parse(code, {
27
+ sourceType: 'unambiguous',
28
+ plugins: [
29
+ 'typescript',
30
+ 'jsx',
31
+ 'classProperties',
32
+ 'objectRestSpread',
33
+ 'dynamicImport',
34
+ 'optionalChaining',
35
+ 'nullishCoalescingOperator',
36
+ ],
37
+ });
38
+ const found = [];
39
+ traverse.default(ast, {
40
+ VariableDeclaration(path) {
41
+ if (path.node.kind !== 'const') return;
42
+ for (const decl of path.node.declarations) {
43
+ const { id, init } = decl;
44
+ if (!init || id.type !== 'Identifier') continue;
45
+ let text = null;
46
+ switch (init.type) {
47
+ case 'TemplateLiteral': {
48
+ // join all raw chunks; ignores embedded ${expr} for simplicity
49
+ text = init.quasis.map(q => q.value.cooked).join('');
50
+ break;
51
+ }
52
+ case 'StringLiteral':
53
+ text = init.value;
54
+ break;
55
+ }
56
+ if (text) {
57
+ const match = text.match(/\b(query|mutation|fragment)\b/i);
58
+ if (match) {
59
+ found.push(text.trim());
60
+ }
61
+ }
62
+ }
63
+ },
64
+ });
65
+
66
+ return found;
67
+ }
68
+
69
+ async function fetchSchema(endpoint) {
70
+ const body = JSON.stringify({ query: getIntrospectionQuery() });
71
+ const res = await fetch(endpoint, {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body
75
+ });
76
+ if (!res.ok) throw new Error(`Introspection query failed: ${res.statusText}`);
77
+ const { data, errors } = await res.json();
78
+ if (errors?.length) throw new Error(`Server returned errors: ${JSON.stringify(errors)}`);
79
+ return buildClientSchema(data);
80
+ }
81
+
82
+ async function validateGqlOperations(endpoint, operation) {
83
+ console.log(`\nValidating against endpoint: ${endpoint}`);
84
+ try {
85
+ const document = parse(operation);
86
+ const errors = validate(await fetchSchema(endpoint), document);
87
+ if (errors.length) {
88
+ console.error('❌ Operation is NOT valid for this schema:');
89
+ errors.forEach(e => console.error('-', e.message));
90
+ process.exitCode = 1;
91
+ } else {
92
+ console.log('✅ Operation is valid!');
93
+ }
94
+ } catch (e) {
95
+ console.error(e);
96
+ process.exitCode = 1;
97
+ }
98
+ }
99
+
100
+ async function getAllOperations(directories) {
101
+ let fullContent = '';
102
+ for (const directory of directories) {
103
+ const files = await walk(path.resolve(directory));
104
+ for (const f of files) {
105
+ const code = await fsPromises.readFile(f, 'utf8');
106
+
107
+ let extracted;
108
+ try {
109
+ extracted = extractConstants(code); // may throw on bad syntax
110
+ } catch (err) {
111
+ console.error(
112
+ `⚠️ Skipping ${path.relative(process.cwd(), f)}\n` +
113
+ ` ${err.message}`
114
+ );
115
+ continue;
116
+ }
117
+ fullContent += extracted;
118
+ }
119
+ }
120
+ return fullContent;
121
+ }
122
+
123
+
124
+
125
+ module.exports = async function main(sources, endpoints) {
126
+ for (const endpoint of endpoints) {
127
+ const operations = await getAllOperations(sources);
128
+ if (!operations) {
129
+ console.error('No GraphQL operations found in the specified directories.');
130
+ process.exitCode = 0;
131
+ return;
132
+ }
133
+ await validateGqlOperations(endpoint, operations);
134
+ }
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe-commerce/elsie",
3
- "version": "1.4.1-alpha001",
3
+ "version": "1.4.1-alpha003",
4
4
  "license": "SEE LICENSE IN LICENSE.md",
5
5
  "description": "Domain Package SDK",
6
6
  "engines": {
@@ -36,8 +36,10 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@babel/core": "^7.24.9",
39
+ "@babel/parser": "^7.24.0",
39
40
  "@babel/preset-env": "^7.24.8",
40
41
  "@babel/preset-typescript": "^7.24.7",
42
+ "@babel/traverse": "^7.24.0",
41
43
  "@chromatic-com/storybook": "^1",
42
44
  "@graphql-codegen/cli": "^5.0.0",
43
45
  "@graphql-codegen/client-preset": "^4.1.0",
@@ -36,6 +36,7 @@ export interface CartItemProps
36
36
  totalExcludingTax?: VNode;
37
37
  sku?: VNode;
38
38
  quantity?: number;
39
+ quantityContent?: VNode;
39
40
  description?: VNode;
40
41
  attributes?: VNode;
41
42
  footer?: VNode;
@@ -45,6 +46,7 @@ export interface CartItemProps
45
46
  discount?: VNode;
46
47
  savings?: VNode;
47
48
  actions?: VNode;
49
+ removeContent?: VNode;
48
50
  loading?: boolean;
49
51
  updating?: boolean;
50
52
  onRemove?: () => void;
@@ -71,7 +73,9 @@ export const CartItem: FunctionComponent<CartItemProps> = ({
71
73
  discount,
72
74
  savings,
73
75
  actions,
76
+ removeContent,
74
77
  quantity,
78
+ quantityContent,
75
79
  description,
76
80
  attributes,
77
81
  footer,
@@ -304,18 +308,20 @@ export const CartItem: FunctionComponent<CartItemProps> = ({
304
308
  ['dropin-cart-item__quantity--edit', !!onQuantity],
305
309
  ])}
306
310
  >
307
- {onQuantity
308
- ? quantityComponent
309
- : quantity && (
310
- <span
311
- className={classes(['dropin-cart-item__quantity__value'])}
312
- >
313
- {labels.quantity}:{' '}
314
- <strong className="dropin-cart-item__quantity__number">
315
- {Number(quantity).toLocaleString(locale)}
316
- </strong>
317
- </span>
318
- )}
311
+ {quantityContent ? (
312
+ <VComponent node={quantityContent} />
313
+ ) : onQuantity ? (
314
+ quantityComponent
315
+ ) : (
316
+ quantity && (
317
+ <span className={classes(['dropin-cart-item__quantity__value'])}>
318
+ {labels.quantity}:{' '}
319
+ <strong className="dropin-cart-item__quantity__number">
320
+ {Number(quantity).toLocaleString(locale)}
321
+ </strong>
322
+ </span>
323
+ )
324
+ )}
319
325
 
320
326
  {/* Warning */}
321
327
  {warning && (
@@ -432,12 +438,17 @@ export const CartItem: FunctionComponent<CartItemProps> = ({
432
438
 
433
439
  {/* Footer */}
434
440
  {footer && (
435
- <VComponent node={footer} className={classes(['dropin-cart-item__footer'])} />
441
+ <VComponent
442
+ node={footer}
443
+ className={classes(['dropin-cart-item__footer'])}
444
+ />
436
445
  )}
437
446
  </div>
438
447
 
439
448
  {/* Remove Item */}
440
- {onRemove && (
449
+ {removeContent ? (
450
+ <VComponent node={removeContent} />
451
+ ) : onRemove ? (
441
452
  <Button
442
453
  data-testid="cart-item-remove-button"
443
454
  className={classes(['dropin-cart-item__remove'])}
@@ -459,7 +470,7 @@ export const CartItem: FunctionComponent<CartItemProps> = ({
459
470
  }
460
471
  disabled={updating}
461
472
  />
462
- )}
473
+ ) : null}
463
474
  </div>
464
475
  );
465
476
  };
@@ -9,13 +9,13 @@
9
9
 
10
10
  import { FunctionComponent } from 'preact';
11
11
  import { HTMLAttributes, useMemo } from 'preact/compat';
12
- import { classes } from '@adobe-commerce/elsie/lib';
12
+ import { classes, getGlobalLocale } from '@adobe-commerce/elsie/lib';
13
13
  import '@adobe-commerce/elsie/components/Price/Price.css';
14
14
 
15
15
  export interface PriceProps
16
16
  extends Omit<HTMLAttributes<HTMLSpanElement>, 'size'> {
17
17
  amount?: number;
18
- currency?: string;
18
+ currency?: string | null;
19
19
  locale?: string;
20
20
  formatOptions?: {
21
21
  [key: string]: any;
@@ -29,7 +29,7 @@ export interface PriceProps
29
29
  export const Price: FunctionComponent<PriceProps> = ({
30
30
  amount = 0,
31
31
  currency,
32
- locale = process.env.LOCALE ?? undefined,
32
+ locale,
33
33
  variant = 'default',
34
34
  weight = 'bold',
35
35
  className,
@@ -39,17 +39,37 @@ export const Price: FunctionComponent<PriceProps> = ({
39
39
  size = 'small',
40
40
  ...props
41
41
  }) => {
42
+ // Determine the locale to use: prop locale > global locale > browser locale
43
+ const effectiveLocale = useMemo(() => {
44
+ if (locale) {
45
+ return locale;
46
+ }
47
+ const globalLocale = getGlobalLocale();
48
+ if (globalLocale) {
49
+ return globalLocale;
50
+ }
51
+ // Fallback to browser locale or default
52
+ return process.env.LOCALE && process.env.LOCALE !== 'undefined' ? process.env.LOCALE : 'en-US';
53
+ }, [locale]);
54
+
42
55
  const formatter = useMemo(
43
- () =>
44
- new Intl.NumberFormat(locale, {
56
+ () => {
57
+ const params: Intl.NumberFormatOptions = {
45
58
  style: 'currency',
46
59
  currency: currency || 'USD',
47
60
  // These options are needed to round to whole numbers if that's what you want.
48
61
  minimumFractionDigits: 2, // (this suffices for whole numbers, but will print 2500.10 as $2,500.1)
49
62
  maximumFractionDigits: 2, // (causes 2500.99 to be printed as $2,501)
50
63
  ...formatOptions,
51
- }),
52
- [locale, currency, formatOptions]
64
+ }
65
+ try {
66
+ return new Intl.NumberFormat(effectiveLocale, params);
67
+ } catch (error) {
68
+ console.error(`Error creating Intl.NumberFormat instance for locale ${effectiveLocale}. Falling back to en-US.`, error);
69
+ return new Intl.NumberFormat('en-US', params);
70
+ }
71
+ },
72
+ [effectiveLocale, currency, formatOptions]
53
73
  );
54
74
 
55
75
  const formattedAmount = useMemo(
@@ -23,7 +23,7 @@
23
23
  }
24
24
 
25
25
  .dropin-product-item-card__image-container {
26
- overflow: visible;
26
+ overflow: hidden;
27
27
  width: 100%;
28
28
  height: auto;
29
29
  }
@@ -74,11 +74,6 @@
74
74
  width: 100%;
75
75
  }
76
76
 
77
- .dropin-product-item-card__image a:focus {
78
- outline: 1px solid var(--color-neutral-400);
79
- outline-offset: 1px;
80
- }
81
-
82
77
  /* Medium (portrait tablets and large phones, 768px and up) */
83
78
  /* @media only screen and (min-width: 768px) { } */
84
79
 
@@ -81,13 +81,9 @@ initializers.setImageParamKeys({
81
81
  extraParam: () => ['extraParam', 'extraValue'],
82
82
  });
83
83
 
84
- // Register Initializers
85
- initializers.register(pkg.initialize, {
86
- langDefinitions,
84
+ initializers.mountImmediately(pkg.initialize, {
85
+ langDefinitions
87
86
  });
88
-
89
- // Mount Initializers
90
- initializers.mount();
91
87
  ```
92
88
 
93
89
  Now, when a dropin uses the Image component to render an image with a width of 300 pixels and quality value of 0.8:
@@ -116,4 +112,70 @@ It renders the following image element:
116
112
  />
117
113
  ```
118
114
 
119
- In this example, the width parameter is mapped to imgWidth and the value of the quality parameter is modified and mapped to imgQuality.
115
+ In this example, the width parameter is mapped to imgWidth and the value of the quality parameter is modified and mapped to imgQuality.
116
+
117
+ ## `setGlobalLocale(locale)`
118
+
119
+ The `setGlobalLocale` method is part of the initializers module in the `@dropins/tools` package.
120
+ It allows you to set a global locale for all drop-ins that use locale-sensitive components like the Price component.
121
+
122
+ ### Default Behavior
123
+
124
+ By default, components use the browser's locale or fallback to 'en-US' if no global locale is set.
125
+
126
+ ### Parameters
127
+
128
+ - `locale` - `string` - The locale string (e.g., 'en-US', 'es-MX', 'fr-FR', 'de-DE').
129
+
130
+ ### Functionality
131
+
132
+ - If a global locale is set via `setGlobalLocale`, it will be used by components that support locale configuration.
133
+ - Component-specific locale props will take precedence over the global locale.
134
+ - If no global locale is set, components will fall back to the browser's locale or a default locale.
135
+
136
+ ### Usage
137
+
138
+ Call the `setGlobalLocale()` function before the `mountImmediately()` function in the application layer.
139
+
140
+ ```javascript
141
+ // Set global locale for consistent formatting across all drop-ins
142
+ initializers.setGlobalLocale('fr-FR');
143
+
144
+ // Register and Mount Initializers immediately
145
+ initializers.mountImmediately(pkg.initialize, {});
146
+ ```
147
+
148
+ Now, when a dropin uses the Price component without specifying a locale prop:
149
+
150
+ ```jsx
151
+ <Price
152
+ amount={100}
153
+ currency="EUR"
154
+ />
155
+ ```
156
+
157
+ It will render with the global locale (fr-FR) formatting:
158
+
159
+ ```html
160
+ <span class="dropin-price dropin-price--default dropin-price--small dropin-price--bold">
161
+ 100,00 €
162
+ </span>
163
+ ```
164
+
165
+ If the same component is used with a specific locale prop, that will take precedence:
166
+
167
+ ```jsx
168
+ <Price
169
+ amount={100}
170
+ currency="EUR"
171
+ locale="en-US"
172
+ />
173
+ ```
174
+
175
+ It will render with the specified locale (en-US) formatting:
176
+
177
+ ```html
178
+ <span class="dropin-price dropin-price--default dropin-price--small dropin-price--bold">
179
+ €100.00
180
+ </span>
181
+ ```
package/src/lib/index.ts CHANGED
@@ -20,6 +20,7 @@ export * from '@adobe-commerce/elsie/lib/types';
20
20
  export * from '@adobe-commerce/elsie/lib/slot';
21
21
  export * from '@adobe-commerce/elsie/lib/vcomponent';
22
22
  export * from '@adobe-commerce/elsie/lib/image-params-keymap';
23
+ export * from '@adobe-commerce/elsie/lib/locale-config';
23
24
  export * from '@adobe-commerce/elsie/lib/is-number';
24
25
  export * from '@adobe-commerce/elsie/lib/deviceUtils';
25
26
  export * from '@adobe-commerce/elsie/lib/get-path-value';
@@ -10,6 +10,7 @@
10
10
  import {
11
11
  Config,
12
12
  setImageParamsKeyMap,
13
+ setGlobalLocale,
13
14
  } from '@adobe-commerce/elsie/lib';
14
15
 
15
16
  type Listener = { off(): void };
@@ -51,10 +52,11 @@ export class Initializer<T> {
51
52
  };
52
53
 
53
54
  this.init = (options) => {
54
- const { imageParamsKeyMap, ...rest } =
55
+ const { imageParamsKeyMap, globalLocale, ...rest } =
55
56
  options as any;
56
57
  this.config.setConfig({ ...this.config.getConfig(), ...rest });
57
58
  setImageParamsKeyMap(imageParamsKeyMap);
59
+ setGlobalLocale(globalLocale);
58
60
  return init(options);
59
61
  };
60
62
  }
@@ -75,6 +77,7 @@ export class initializers {
75
77
  static _initializers: Initializers = [];
76
78
  static _mounted: boolean = false;
77
79
  static _imageParamsKeyMap: { [key: string]: string } | undefined = undefined;
80
+ static _globalLocale: string | undefined = undefined;
78
81
  /**
79
82
  * Registers a new initializer. If the initializers have already been mounted,it immediately binds the event listeners and initializes the API for the new initializer.
80
83
  * @param initializer - The initializer to register.
@@ -99,10 +102,11 @@ export class initializers {
99
102
  options?: { [key: string]: any }
100
103
  ) {
101
104
  initializer.listeners?.(options);
102
- await initializer.init?.({
103
- imageParamsKeyMap: initializers._imageParamsKeyMap,
104
- ...options,
105
- });
105
+ await initializer.init?.({
106
+ imageParamsKeyMap: initializers._imageParamsKeyMap,
107
+ globalLocale: initializers._globalLocale,
108
+ ...options,
109
+ });
106
110
  }
107
111
 
108
112
  /**
@@ -120,6 +124,7 @@ export class initializers {
120
124
  initializers._initializers?.forEach(([initializer, options]) => {
121
125
  initializer.init?.({
122
126
  imageParamsKeyMap: initializers._imageParamsKeyMap,
127
+ globalLocale: initializers._globalLocale,
123
128
  ...options,
124
129
  });
125
130
  });
@@ -131,4 +136,12 @@ export class initializers {
131
136
  static setImageParamKeys(params: { [key: string]: any }) {
132
137
  initializers._imageParamsKeyMap = params;
133
138
  }
139
+
140
+ /**
141
+ * Sets the global locale. This locale is used by components that need consistent formatting regardless of the user's browser locale.
142
+ * @param locale - The locale string (e.g., 'en-US', 'es-MX', 'fr-FR').
143
+ */
144
+ static setGlobalLocale(locale: string) {
145
+ initializers._globalLocale = locale;
146
+ }
134
147
  }
@@ -0,0 +1,34 @@
1
+ /********************************************************************
2
+ * Copyright 2024 Adobe
3
+ * All Rights Reserved.
4
+ *
5
+ * NOTICE: Adobe permits you to use, modify, and distribute this
6
+ * file in accordance with the terms of the Adobe license agreement
7
+ * accompanying it.
8
+ *******************************************************************/
9
+
10
+ class LocaleConfig {
11
+ private _locale?: string | undefined;
12
+
13
+ get locale() {
14
+ return this._locale;
15
+ }
16
+
17
+ set locale(value: typeof this._locale) {
18
+ this._locale = value;
19
+ }
20
+
21
+ public getMethods() {
22
+ return {
23
+ setLocale: (value: typeof this._locale) => {
24
+ this.locale = value;
25
+ },
26
+ getLocale: () => this.locale,
27
+ };
28
+ }
29
+ }
30
+
31
+ const localeConfig = new LocaleConfig();
32
+
33
+ export const { setLocale: setGlobalLocale, getLocale: getGlobalLocale } =
34
+ localeConfig.getMethods();