@dream-encode/wp-js-plugin-utils 0.3.0 → 0.6.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.
@@ -0,0 +1,112 @@
1
+ /*
2
+ * Generic styling for the wp-plugin-utils Progress component.
3
+ *
4
+ * Targets the `<progress>` element rendered by `<Progress>`. Apply to a
5
+ * selector that matches the `className` prop (defaults to `progress-bar`).
6
+ *
7
+ * .progress-bar {
8
+ * @include de-wp-plugin-utils-progress();
9
+ * }
10
+ */
11
+
12
+ $de-wp-plugin-utils-progress-waiting-color: #f1efef !default;
13
+ $de-wp-plugin-utils-progress-processing-color: #006FE6 !default;
14
+ $de-wp-plugin-utils-progress-complete-color: #65C728 !default;
15
+ $de-wp-plugin-utils-progress-border-radius: 5px !default;
16
+ $de-wp-plugin-utils-progress-shimmer-end-color: rgba(255, 255, 255, 0.3) !default;
17
+
18
+ @mixin de-wp-plugin-utils-progress(
19
+ $waiting-color: $de-wp-plugin-utils-progress-waiting-color,
20
+ $processing-color: $de-wp-plugin-utils-progress-processing-color,
21
+ $complete-color: $de-wp-plugin-utils-progress-complete-color,
22
+ $border-radius: $de-wp-plugin-utils-progress-border-radius,
23
+ $shimmer-end-color: $de-wp-plugin-utils-progress-shimmer-end-color
24
+ ) {
25
+ height: 20px;
26
+ width: 100%;
27
+ border-radius: $border-radius;
28
+ position: relative;
29
+
30
+ &::-webkit-progress-bar {
31
+ background: $waiting-color;
32
+ border-radius: $border-radius;
33
+ }
34
+
35
+ &.processing {
36
+ &::after {
37
+ content: '';
38
+ position: absolute;
39
+ top: 0;
40
+ left: 0;
41
+ width: 100%;
42
+ height: 100%;
43
+ background: linear-gradient(
44
+ 90deg,
45
+ transparent 0%,
46
+ $shimmer-end-color 50%,
47
+ transparent 100%
48
+ );
49
+ background-size: 200% 100%;
50
+ border-radius: $border-radius;
51
+ animation: de-wp-plugin-utils-progress-shimmer 2s infinite linear;
52
+ pointer-events: none;
53
+ }
54
+
55
+ &::-webkit-progress-value {
56
+ background: $processing-color;
57
+ animation: none;
58
+ }
59
+
60
+ &::-webkit-progress-bar {
61
+ animation: none;
62
+ }
63
+ }
64
+
65
+ &.pending,
66
+ &.queued {
67
+ &::-webkit-progress-value {
68
+ background: $waiting-color;
69
+ animation-name: de-wp-plugin-utils-progress-pulse-waiting;
70
+ animation-duration: 2s;
71
+ animation-iteration-count: infinite;
72
+ }
73
+
74
+ &::-webkit-progress-bar {
75
+ animation-name: de-wp-plugin-utils-progress-pulse-waiting;
76
+ animation-duration: 2s;
77
+ animation-iteration-count: infinite;
78
+ }
79
+ }
80
+
81
+ &.complete {
82
+ &::-webkit-progress-value {
83
+ background: $complete-color;
84
+ }
85
+ }
86
+
87
+ &::-webkit-progress-value,
88
+ &[value]::-webkit-progress-value {
89
+ border-radius: $border-radius;
90
+ transition: all 1s ease;
91
+ }
92
+ }
93
+
94
+ @keyframes de-wp-plugin-utils-progress-pulse-waiting {
95
+ 0% {
96
+ background-color: hsl( 200, 20%, 70% );
97
+ }
98
+
99
+ 100% {
100
+ background-color: hsl( 200, 20%, 95% );
101
+ }
102
+ }
103
+
104
+ @keyframes de-wp-plugin-utils-progress-shimmer {
105
+ 0% {
106
+ background-position: -200% 0;
107
+ }
108
+
109
+ 100% {
110
+ background-position: 200% 0;
111
+ }
112
+ }
@@ -33,7 +33,39 @@ Object.defineProperty(exports, "NotificationsList", {
33
33
  return _NotificationsDrawer.NotificationsList;
34
34
  }
35
35
  });
36
+ Object.defineProperty(exports, "ProcessInfo", {
37
+ enumerable: true,
38
+ get: function () {
39
+ return _Process.default;
40
+ }
41
+ });
42
+ Object.defineProperty(exports, "ProcessInfoRow", {
43
+ enumerable: true,
44
+ get: function () {
45
+ return _Process.ProcessInfoRow;
46
+ }
47
+ });
48
+ Object.defineProperty(exports, "ProcessStatusInfo", {
49
+ enumerable: true,
50
+ get: function () {
51
+ return _Process.ProcessStatusInfo;
52
+ }
53
+ });
54
+ Object.defineProperty(exports, "ProcessStatusSummary", {
55
+ enumerable: true,
56
+ get: function () {
57
+ return _Process.ProcessStatusSummary;
58
+ }
59
+ });
60
+ Object.defineProperty(exports, "Progress", {
61
+ enumerable: true,
62
+ get: function () {
63
+ return _Progress.default;
64
+ }
65
+ });
36
66
  var _Notices = _interopRequireDefault(require("./Notices"));
37
67
  var _NotificationsDrawer = _interopRequireWildcard(require("./NotificationsDrawer"));
68
+ var _Process = _interopRequireWildcard(require("./Process"));
69
+ var _Progress = _interopRequireDefault(require("./Progress"));
38
70
  function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
39
71
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
@@ -0,0 +1,192 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+ var _components = require("@wordpress/components");
8
+ var _element = require("@wordpress/element");
9
+ var _i18n = require("@wordpress/i18n");
10
+ var _Notices = _interopRequireDefault(require("../components/Notices"));
11
+ var _SettingsRail = _interopRequireDefault(require("./SettingsRail"));
12
+ var _useActiveSection = _interopRequireDefault(require("./useActiveSection"));
13
+ var _jsxRuntime = require("react/jsx-runtime");
14
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
15
+ /**
16
+ * Settings page shell built around a vertical section rail.
17
+ *
18
+ * Sections are declared rather than nested, so the rail, the open pane, the unsaved
19
+ * markers and the deep link all read from one list. Each entry accepts:
20
+ *
21
+ * key Unique section key, and the URL hash that opens it.
22
+ * title Rail label and pane heading.
23
+ * group Optional rail group heading. Consecutive sections sharing a group
24
+ * sit under one heading.
25
+ * description Optional sentence under the pane heading.
26
+ * badge Optional count shown against the rail item.
27
+ * type 'actions' for a section whose controls run immediately.
28
+ * render Called with the settings object, returns the pane content.
29
+ *
30
+ * A page declaring a single section renders without the rail, since a rail holding
31
+ * one item is chrome rather than navigation. Its status readout moves to the header.
32
+ *
33
+ * A section is marked as edited by matching the `section` declared on each field in
34
+ * `createUseSettings` against the section key, so a field does not have to be listed
35
+ * twice.
36
+ *
37
+ * Everything the page writes goes through the single Save in the header, which names
38
+ * how many fields it will write. A section of type 'actions' hides Save entirely,
39
+ * because nothing on such a section is written by it - a button there has already
40
+ * done its work by the time it returns. Unsaved edits made elsewhere are still
41
+ * reachable from an actions section through the count, which opens the first section
42
+ * holding one.
43
+ *
44
+ * @since 0.6.0
45
+ * @param {Object} props
46
+ * @param {string} props.title Page title.
47
+ * @param {string} [props.appVersion] Optional version shown beside the title.
48
+ * @param {Object} props.settings Object returned from a `useSettings` hook.
49
+ * @param {Array} props.sections Section definitions.
50
+ * @param {Object} [props.status] Optional readout for the rail foot, as `{ tone, text }`.
51
+ * @param {Function} [props.onSave] Optional override for the save handler.
52
+ * @param {string} [props.textDomain] Text domain for translated UI strings.
53
+ * @return {JSX.Element}
54
+ */const RailSettingsPage = ({
55
+ title,
56
+ appVersion,
57
+ settings,
58
+ sections = [],
59
+ status,
60
+ onSave,
61
+ textDomain = 'default'
62
+ }) => {
63
+ const {
64
+ settingsLoaded,
65
+ settingsSaving,
66
+ saveSettings,
67
+ isDirty = false,
68
+ dirtyCount = 0,
69
+ dirtySections = [],
70
+ discardChanges
71
+ } = settings;
72
+ const [activeKey, selectSection] = (0, _useActiveSection.default)(sections);
73
+ const activeSection = sections.find(section => section.key === activeKey) || sections[0];
74
+ const isActionsSection = 'actions' === activeSection?.type;
75
+ const showRail = sections.length > 1;
76
+ (0, _element.useEffect)(() => {
77
+ if (!isDirty) {
78
+ return undefined;
79
+ }
80
+ const handleBeforeUnload = event => {
81
+ event.preventDefault();
82
+ event.returnValue = '';
83
+ };
84
+ window.addEventListener('beforeunload', handleBeforeUnload);
85
+ return () => {
86
+ window.removeEventListener('beforeunload', handleBeforeUnload);
87
+ };
88
+ }, [isDirty]);
89
+ const handleSave = async event => {
90
+ event.preventDefault();
91
+ if ('function' === typeof onSave) {
92
+ await onSave(event);
93
+ return;
94
+ }
95
+ await saveSettings();
96
+ };
97
+ const unsavedLabel = (0, _i18n.sprintf)(/* translators: %d: number of settings fields holding an unsaved edit. */
98
+ (0, _i18n._n)('%d unsaved change', '%d unsaved changes', dirtyCount, textDomain), dirtyCount);
99
+ const saveLabel = () => {
100
+ if (settingsSaving) {
101
+ return (0, _i18n.__)('Saving…', textDomain);
102
+ }
103
+ if (!isDirty) {
104
+ return (0, _i18n.__)('Saved', textDomain);
105
+ }
106
+ return (0, _i18n.__)('Save', textDomain);
107
+ };
108
+ if (!activeSection) {
109
+ return null;
110
+ }
111
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
112
+ className: "de-settings",
113
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
114
+ className: "de-settings__header",
115
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
116
+ className: "de-settings__identity",
117
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("h1", {
118
+ className: "de-settings__title",
119
+ children: title
120
+ }), !!appVersion && /*#__PURE__*/(0, _jsxRuntime.jsxs)("span", {
121
+ className: "de-settings__version",
122
+ children: ["v", appVersion]
123
+ })]
124
+ }), !showRail && !!status?.text && /*#__PURE__*/(0, _jsxRuntime.jsxs)("span", {
125
+ className: `de-settings__status is-${status.tone || 'neutral'}`,
126
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
127
+ className: "de-settings__status-led",
128
+ "aria-hidden": "true"
129
+ }), status.text]
130
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
131
+ className: "de-settings__header-actions",
132
+ children: isActionsSection ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_element.Fragment, {
133
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
134
+ className: "de-settings__note",
135
+ children: (0, _i18n.__)('Actions here run immediately.', textDomain)
136
+ }), isDirty && dirtySections.length > 0 && /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.Button, {
137
+ variant: "tertiary",
138
+ onClick: () => selectSection(dirtySections[0]),
139
+ children: unsavedLabel
140
+ })]
141
+ }) : /*#__PURE__*/(0, _jsxRuntime.jsxs)(_element.Fragment, {
142
+ children: [isDirty && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_element.Fragment, {
143
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
144
+ className: "de-settings__unsaved",
145
+ children: unsavedLabel
146
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.Button, {
147
+ variant: "tertiary",
148
+ onClick: discardChanges,
149
+ disabled: settingsSaving,
150
+ children: (0, _i18n.__)('Discard', textDomain)
151
+ })]
152
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.Button, {
153
+ variant: "primary",
154
+ isBusy: settingsSaving,
155
+ disabled: !settingsLoaded || settingsSaving || !isDirty,
156
+ onClick: handleSave,
157
+ children: saveLabel()
158
+ })]
159
+ })
160
+ })]
161
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
162
+ className: showRail ? 'de-settings__body' : 'de-settings__body is-solo',
163
+ children: [showRail && /*#__PURE__*/(0, _jsxRuntime.jsx)(_SettingsRail.default, {
164
+ sections: sections,
165
+ activeKey: activeSection.key,
166
+ onSelect: selectSection,
167
+ dirtySections: dirtySections,
168
+ status: status,
169
+ textDomain: textDomain
170
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
171
+ className: "de-settings__pane",
172
+ id: `de-settings-pane-${activeSection.key}`,
173
+ role: "tabpanel",
174
+ "aria-labelledby": `de-settings-tab-${activeSection.key}`,
175
+ tabIndex: 0,
176
+ children: !settingsLoaded ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.Placeholder, {
177
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_components.Spinner, {})
178
+ }) : /*#__PURE__*/(0, _jsxRuntime.jsxs)(_element.Fragment, {
179
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_Notices.default, {}), /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
180
+ className: "de-settings__pane-head",
181
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("h2", {
182
+ children: activeSection.title
183
+ }), !!activeSection.description && /*#__PURE__*/(0, _jsxRuntime.jsx)("p", {
184
+ children: activeSection.description
185
+ })]
186
+ }), activeSection.render(settings)]
187
+ })
188
+ })]
189
+ })]
190
+ });
191
+ };
192
+ var _default = exports.default = RailSettingsPage;
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+ var _element = require("@wordpress/element");
8
+ var _i18n = require("@wordpress/i18n");
9
+ var _jsxRuntime = require("react/jsx-runtime");
10
+ /**
11
+ * Vertical section navigation for the settings shell.
12
+ *
13
+ * Implements the tab pattern: one item is tabbable at a time and the arrow keys move
14
+ * between them, so the rail costs a single tab stop no matter how many sections a
15
+ * plugin registers.
16
+ *
17
+ * @since 0.6.0
18
+ * @param {Object} props
19
+ * @param {Array} props.sections Section definitions.
20
+ * @param {string} props.activeKey Key of the open section.
21
+ * @param {Function} props.onSelect Called with a section key when one is chosen.
22
+ * @param {Array} [props.dirtySections] Keys of sections holding an unsaved edit.
23
+ * @param {Object} [props.status] Optional readout for the rail foot, as `{ tone, text }`.
24
+ * @param {string} [props.textDomain] Text domain for translated UI strings.
25
+ * @return {JSX.Element}
26
+ */const SettingsRail = ({
27
+ sections,
28
+ activeKey,
29
+ onSelect,
30
+ dirtySections = [],
31
+ status,
32
+ textDomain = 'default'
33
+ }) => {
34
+ const itemRefs = (0, _element.useRef)({});
35
+ const moveFocus = (fromIndex, step) => {
36
+ const nextIndex = (fromIndex + step + sections.length) % sections.length;
37
+ const nextKey = sections[nextIndex].key;
38
+ onSelect(nextKey);
39
+ itemRefs.current[nextKey]?.focus();
40
+ };
41
+ const handleKeyDown = (event, index) => {
42
+ if ('ArrowDown' === event.key || 'ArrowRight' === event.key) {
43
+ event.preventDefault();
44
+ moveFocus(index, 1);
45
+ return;
46
+ }
47
+ if ('ArrowUp' === event.key || 'ArrowLeft' === event.key) {
48
+ event.preventDefault();
49
+ moveFocus(index, -1);
50
+ return;
51
+ }
52
+ if ('Home' === event.key) {
53
+ event.preventDefault();
54
+ moveFocus(-1, 1);
55
+ return;
56
+ }
57
+ if ('End' === event.key) {
58
+ event.preventDefault();
59
+ moveFocus(0, -1);
60
+ }
61
+ };
62
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
63
+ className: "de-settings__rail",
64
+ children: /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", {
65
+ className: "de-settings__rail-inner",
66
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
67
+ className: "de-settings__rail-list",
68
+ role: "tablist",
69
+ "aria-orientation": "vertical",
70
+ "aria-label": (0, _i18n.__)('Settings sections', textDomain),
71
+ children: sections.map((section, index) => {
72
+ const previousGroup = index > 0 ? sections[index - 1].group : null;
73
+ const isActive = section.key === activeKey;
74
+ const isDirty = dirtySections.includes(section.key);
75
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_element.Fragment, {
76
+ children: [!!section.group && section.group !== previousGroup && /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
77
+ className: "de-settings__rail-group",
78
+ role: "presentation",
79
+ children: section.group
80
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)("button", {
81
+ type: "button",
82
+ role: "tab",
83
+ id: `de-settings-tab-${section.key}`,
84
+ className: "de-settings__rail-item",
85
+ "aria-selected": isActive,
86
+ "aria-controls": isActive ? `de-settings-pane-${section.key}` : undefined,
87
+ tabIndex: isActive ? 0 : -1,
88
+ ref: element => {
89
+ itemRefs.current[section.key] = element;
90
+ },
91
+ onClick: () => onSelect(section.key),
92
+ onKeyDown: event => handleKeyDown(event, index),
93
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
94
+ className: "de-settings__rail-title",
95
+ children: section.title
96
+ }), undefined !== section.badge && null !== section.badge && /*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
97
+ className: "de-settings__rail-badge",
98
+ children: section.badge
99
+ }), isDirty && /*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
100
+ className: "de-settings__rail-dirty",
101
+ role: "img",
102
+ "aria-label": (0, _i18n.__)('Unsaved changes', textDomain)
103
+ })]
104
+ })]
105
+ }, section.key);
106
+ })
107
+ }), !!status?.text && /*#__PURE__*/(0, _jsxRuntime.jsx)("div", {
108
+ className: "de-settings__rail-foot",
109
+ children: /*#__PURE__*/(0, _jsxRuntime.jsxs)("span", {
110
+ className: `de-settings__status is-${status.tone || 'neutral'}`,
111
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)("span", {
112
+ className: "de-settings__status-led",
113
+ "aria-hidden": "true"
114
+ }), status.text]
115
+ })
116
+ })]
117
+ })
118
+ });
119
+ };
120
+ var _default = exports.default = SettingsRail;
@@ -10,17 +10,46 @@ var _apiFetch = _interopRequireDefault(require("@wordpress/api-fetch"));
10
10
  var _data = require("@wordpress/data");
11
11
  var _notices = require("@wordpress/notices");
12
12
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
13
+ /**
14
+ * Serialize a value so two settings payloads can be compared by content.
15
+ *
16
+ * Object keys are sorted before serializing. The working values and the values that
17
+ * came back from the REST endpoint routinely carry the same keys in a different
18
+ * order - an object rebuilt through a spread is the ordinary case - and a plain
19
+ * `JSON.stringify` comparison reports that as an edit.
20
+ *
21
+ * @since 0.6.0
22
+ * @param {*} value Value to serialize.
23
+ * @return {string} Stable serialization of the value.
24
+ */
25
+ const stableStringify = value => {
26
+ if (null === value || undefined === value || 'object' !== typeof value) {
27
+ return JSON.stringify(value ?? null);
28
+ }
29
+ if (Array.isArray(value)) {
30
+ return `[${value.map(item => stableStringify(item)).join(',')}]`;
31
+ }
32
+ const pairs = Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`);
33
+ return `{${pairs.join(',')}}`;
34
+ };
35
+
13
36
  /**
14
37
  * Create a `useSettings` hook for a plugin.
15
38
  *
16
39
  * Reads/writes settings via the WordPress core REST settings endpoint
17
40
  * (`/wp/v2/settings`), keyed by `optionName`. Each entry in `fields`
18
- * defines a field with `key` (option key), optional `defaultValue`, and
19
- * optional state name overrides.
41
+ * defines a field with `key` (option key), optional `defaultValue`,
42
+ * optional state name overrides, and an optional `section` naming the
43
+ * settings section the field belongs to.
44
+ *
45
+ * The hook keeps the values it loaded, so it can report what has been edited
46
+ * since. That is what lets a settings page enable Save only when there is
47
+ * something to write, say how much it will write, mark the sections holding an
48
+ * edit, and offer a Discard that actually restores something.
20
49
  *
21
50
  * @param {Object} config Hook configuration.
22
51
  * @param {string} config.optionName Top-level option key on the settings endpoint.
23
- * @param {Array} config.fields Field definitions: `{ key, defaultValue, stateName, setterName }`.
52
+ * @param {Array} config.fields Field definitions: `{ key, defaultValue, stateName, setterName, section }`.
24
53
  * @param {string} [config.textDomain] Text domain for translated notice strings.
25
54
  * @param {string} [config.path] REST path (default: '/wp/v2/settings').
26
55
  * @return {Function} A `useSettings` React hook.
@@ -37,6 +66,13 @@ const createUseSettings = config => {
37
66
  }
38
67
  const toCamel = key => key.replace(/[_-](.)/g, (_m, c) => c.toUpperCase());
39
68
  const ucFirst = s => s.charAt(0).toUpperCase() + s.slice(1);
69
+ const sectionByStateName = {};
70
+ fields.forEach(field => {
71
+ if (!field.section) {
72
+ return;
73
+ }
74
+ sectionByStateName[field.stateName || toCamel(field.key)] = field.section;
75
+ });
40
76
  return function useSettings() {
41
77
  const {
42
78
  createSuccessNotice,
@@ -50,6 +86,8 @@ const createUseSettings = config => {
50
86
  initialState[stateName] = field.defaultValue !== undefined ? field.defaultValue : '';
51
87
  });
52
88
  const [values, setValues] = (0, _element.useState)(initialState);
89
+ const [baseline, setBaseline] = (0, _element.useState)(null);
90
+ const baselineTaken = (0, _element.useRef)(false);
53
91
  const setField = stateName => value => {
54
92
  setValues(prev => ({
55
93
  ...prev,
@@ -74,27 +112,67 @@ const createUseSettings = config => {
74
112
  updateSettingsLoaded(true);
75
113
  });
76
114
  }, []);
115
+ (0, _element.useEffect)(() => {
116
+ if (!settingsLoaded || baselineTaken.current) {
117
+ return;
118
+ }
119
+ baselineTaken.current = true;
120
+ setBaseline(values);
121
+ }, [settingsLoaded, values]);
122
+ const dirtyFields = (0, _element.useMemo)(() => {
123
+ if (!baseline) {
124
+ return [];
125
+ }
126
+ return Object.keys(values).filter(key => {
127
+ return stableStringify(values[key]) !== stableStringify(baseline[key]);
128
+ });
129
+ }, [values, baseline]);
130
+ const dirtySections = (0, _element.useMemo)(() => {
131
+ const sections = [];
132
+ dirtyFields.forEach(stateName => {
133
+ const section = sectionByStateName[stateName];
134
+ if (section && !sections.includes(section)) {
135
+ sections.push(section);
136
+ }
137
+ });
138
+ return sections;
139
+ }, [dirtyFields]);
140
+ const discardChanges = () => {
141
+ if (!baseline) {
142
+ return;
143
+ }
144
+ setValues(baseline);
145
+ };
77
146
  const saveSettings = async () => {
78
147
  updateSettingsSaving(true);
148
+ const written = values;
79
149
  const optionData = {};
80
150
  fields.forEach(field => {
81
151
  const stateName = field.stateName || toCamel(field.key);
82
152
  optionData[field.key] = values[stateName];
83
153
  });
84
- const saveResult = await (0, _apiFetch.default)({
85
- path,
86
- method: 'POST',
87
- data: {
88
- [optionName]: optionData
89
- }
90
- });
91
- if (!saveResult) {
154
+ let saveResult;
155
+ try {
156
+ saveResult = await (0, _apiFetch.default)({
157
+ path,
158
+ method: 'POST',
159
+ data: {
160
+ [optionName]: optionData
161
+ }
162
+ });
163
+ } catch (error) {
92
164
  updateSettingsSaving(false);
93
165
  createErrorNotice((0, _i18n.sprintf)(/* translators: %s: Error message. */
94
- (0, _i18n.__)('Error saving settings: %s.', textDomain), saveResult?.message ?? (0, _i18n.__)('Unknown error', textDomain)));
166
+ (0, _i18n.__)('Error saving settings: %s.', textDomain), error?.message ?? (0, _i18n.__)('Unknown error', textDomain)));
95
167
  return;
96
168
  }
97
169
  updateSettingsSaving(false);
170
+ if (!saveResult) {
171
+ createErrorNotice((0, _i18n.sprintf)(/* translators: %s: Error message. */
172
+ (0, _i18n.__)('Error saving settings: %s.', textDomain), (0, _i18n.__)('Unknown error', textDomain)));
173
+ return;
174
+ }
175
+ setBaseline(written);
98
176
  createSuccessNotice((0, _i18n.__)('Settings saved.', textDomain));
99
177
  };
100
178
  const exposed = {
@@ -104,7 +182,12 @@ const createUseSettings = config => {
104
182
  updateSettingsSaving,
105
183
  values,
106
184
  setValues,
107
- saveSettings
185
+ saveSettings,
186
+ isDirty: dirtyFields.length > 0,
187
+ dirtyCount: dirtyFields.length,
188
+ dirtyFields,
189
+ dirtySections,
190
+ discardChanges
108
191
  };
109
192
  fields.forEach(field => {
110
193
  const stateName = field.stateName || toCamel(field.key);
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+ var _i18n = require("@wordpress/i18n");
8
+ /**
9
+ * Describe a plugin log level as a status readout for the settings rail.
10
+ *
11
+ * Debug is called out in amber because it is the level that costs something to leave
12
+ * switched on, and a settings page is usually the only place that gets noticed.
13
+ *
14
+ * @since 0.6.0
15
+ * @param {string} level Configured log level.
16
+ * @param {string} [textDomain] Text domain for translated UI strings.
17
+ * @return {Object} Status readout, as `{ tone, text }`.
18
+ */
19
+ const getLogLevelStatus = (level, textDomain = 'default') => {
20
+ if (!level || 'off' === level) {
21
+ return {
22
+ tone: 'neutral',
23
+ text: (0, _i18n.__)('Logging off', textDomain)
24
+ };
25
+ }
26
+ const label = level.charAt(0).toUpperCase() + level.slice(1);
27
+ return {
28
+ tone: 'debug' === level ? 'warn' : 'ok',
29
+ text: (0, _i18n.sprintf)(/* translators: %s: configured log level, such as Error or Debug. */
30
+ (0, _i18n.__)('Logging: %s', textDomain), label)
31
+ };
32
+ };
33
+ var _default = exports.default = getLogLevelStatus;