nautobot 3.0.0rc1__py3-none-any.whl → 3.0.1__py3-none-any.whl

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.

Potentially problematic release.


This version of nautobot might be problematic. Click here for more details.

Files changed (103) hide show
  1. nautobot/apps/forms.py +8 -0
  2. nautobot/apps/templatetags.py +231 -0
  3. nautobot/apps/testing.py +11 -1
  4. nautobot/apps/ui.py +21 -1
  5. nautobot/apps/utils.py +26 -1
  6. nautobot/core/celery/__init__.py +46 -1
  7. nautobot/core/cli/bootstrap_v3_to_v5.py +185 -44
  8. nautobot/core/cli/bootstrap_v3_to_v5_changes.yaml +314 -0
  9. nautobot/core/graphql/generators.py +2 -2
  10. nautobot/core/jobs/bulk_actions.py +12 -6
  11. nautobot/core/jobs/cleanup.py +13 -1
  12. nautobot/core/settings.py +13 -0
  13. nautobot/core/settings.yaml +22 -0
  14. nautobot/core/settings_funcs.py +11 -1
  15. nautobot/core/tables.py +19 -1
  16. nautobot/core/templates/components/panel/body_wrapper_generic_table.html +1 -1
  17. nautobot/core/templates/components/panel/header_extra_content_table.html +9 -3
  18. nautobot/core/templates/generic/object_create.html +1 -1
  19. nautobot/core/templates/inc/header.html +9 -10
  20. nautobot/core/templates/login.html +16 -1
  21. nautobot/core/templates/nautobot_config.py.j2 +14 -1
  22. nautobot/core/templates/redoc_ui.html +3 -0
  23. nautobot/core/templatetags/helpers.py +3 -3
  24. nautobot/core/testing/views.py +3 -1
  25. nautobot/core/tests/test_graphql.py +13 -0
  26. nautobot/core/tests/test_jobs.py +118 -0
  27. nautobot/core/tests/test_views.py +24 -0
  28. nautobot/core/ui/bulk_buttons.py +2 -3
  29. nautobot/core/utils/lookup.py +2 -3
  30. nautobot/core/utils/permissions.py +1 -1
  31. nautobot/core/views/generic.py +1 -0
  32. nautobot/core/views/mixins.py +37 -10
  33. nautobot/core/views/renderers.py +1 -0
  34. nautobot/core/views/utils.py +3 -3
  35. nautobot/data_validation/views.py +1 -9
  36. nautobot/dcim/forms.py +9 -9
  37. nautobot/dcim/models/devices.py +3 -3
  38. nautobot/dcim/tables/power.py +3 -0
  39. nautobot/dcim/templates/dcim/controllermanageddevicegroup_create.html +1 -1
  40. nautobot/dcim/views.py +30 -44
  41. nautobot/extras/api/views.py +14 -3
  42. nautobot/extras/choices.py +3 -0
  43. nautobot/extras/jobs.py +48 -2
  44. nautobot/extras/migrations/0132_approval_workflow_seed_data.py +127 -0
  45. nautobot/extras/models/approvals.py +11 -1
  46. nautobot/extras/models/models.py +19 -0
  47. nautobot/extras/models/relationships.py +3 -1
  48. nautobot/extras/tables.py +35 -18
  49. nautobot/extras/templates/extras/approval_workflow/approve.html +9 -2
  50. nautobot/extras/templates/extras/approval_workflow/deny.html +9 -3
  51. nautobot/extras/templates/extras/approvalworkflowdefinition_update.html +1 -1
  52. nautobot/extras/templates/extras/customfield_update.html +1 -1
  53. nautobot/extras/templates/extras/dynamicgroup_update.html +2 -2
  54. nautobot/extras/templates/extras/inc/approval_buttons_column.html +10 -2
  55. nautobot/extras/templates/extras/inc/job_tiles.html +2 -2
  56. nautobot/extras/templates/extras/inc/jobresult.html +1 -1
  57. nautobot/extras/templates/extras/metadatatype_create.html +1 -1
  58. nautobot/extras/templates/extras/object_approvalworkflow.html +2 -3
  59. nautobot/extras/templates/extras/secretsgroup_update.html +1 -1
  60. nautobot/extras/tests/test_api.py +57 -3
  61. nautobot/extras/tests/test_customfields_filters.py +84 -4
  62. nautobot/extras/tests/test_views.py +323 -6
  63. nautobot/extras/views.py +114 -39
  64. nautobot/ipam/constants.py +2 -2
  65. nautobot/ipam/tables.py +7 -6
  66. nautobot/load_balancers/constants.py +6 -0
  67. nautobot/load_balancers/migrations/0001_initial.py +14 -3
  68. nautobot/load_balancers/models.py +5 -4
  69. nautobot/load_balancers/tables.py +5 -0
  70. nautobot/project-static/dist/css/nautobot.css +1 -1
  71. nautobot/project-static/dist/css/nautobot.css.map +1 -1
  72. nautobot/project-static/dist/js/graphql-libraries.js +1 -1
  73. nautobot/project-static/dist/js/graphql-libraries.js.map +1 -1
  74. nautobot/project-static/dist/js/libraries.js +1 -1
  75. nautobot/project-static/dist/js/libraries.js.LICENSE.txt +38 -2
  76. nautobot/project-static/dist/js/libraries.js.map +1 -1
  77. nautobot/project-static/dist/js/nautobot-graphiql.js +1 -1
  78. nautobot/project-static/dist/js/nautobot-graphiql.js.map +1 -1
  79. nautobot/project-static/dist/js/nautobot.js +1 -1
  80. nautobot/project-static/dist/js/nautobot.js.map +1 -1
  81. nautobot/project-static/img/dark-theme.png +0 -0
  82. nautobot/project-static/img/light-theme.png +0 -0
  83. nautobot/project-static/img/system-theme.png +0 -0
  84. nautobot/project-static/js/forms.js +1 -85
  85. nautobot/tenancy/tables.py +3 -2
  86. nautobot/tenancy/views.py +3 -2
  87. nautobot/ui/package-lock.json +553 -569
  88. nautobot/ui/package.json +10 -10
  89. nautobot/ui/src/js/checkbox.js +132 -0
  90. nautobot/ui/src/js/nautobot.js +6 -0
  91. nautobot/ui/src/js/select2.js +69 -73
  92. nautobot/ui/src/js/theme.js +129 -39
  93. nautobot/ui/src/scss/nautobot.scss +11 -1
  94. nautobot/vpn/templates/vpn/vpnprofile_create.html +2 -2
  95. nautobot/wireless/filters.py +15 -1
  96. nautobot/wireless/tables.py +18 -14
  97. nautobot/wireless/templates/wireless/wirelessnetwork_create.html +1 -1
  98. {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/METADATA +2 -2
  99. {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/RECORD +103 -98
  100. {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/LICENSE.txt +0 -0
  101. {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/NOTICE +0 -0
  102. {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/WHEEL +0 -0
  103. {nautobot-3.0.0rc1.dist-info → nautobot-3.0.1.dist-info}/entry_points.txt +0 -0
nautobot/ui/package.json CHANGED
@@ -13,18 +13,18 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@graphiql/plugin-explorer": "0.1.15",
16
- "@graphiql/toolkit": "^0.11.1",
16
+ "@graphiql/toolkit": "^0.11.3",
17
17
  "@mdi/font": "^7.4.47",
18
18
  "@popperjs/core": "^2.11.8",
19
- "bootstrap": "^5.3.3",
20
- "clipboard": "2.0.9",
19
+ "bootstrap": "^5.3.8",
20
+ "clipboard": "2.0.11",
21
21
  "echarts": "^6.0.0",
22
- "flatpickr": "4.6.9",
22
+ "flatpickr": "4.6.13",
23
23
  "graphiql": "2.4.7",
24
24
  "graphql": "16.10.0",
25
25
  "graphql-ws": "5.13.1",
26
26
  "highlight.js": "^11.11.1",
27
- "htmx.org": "^2.0.6",
27
+ "htmx.org": "^2.0.8",
28
28
  "jquery": "^3.7.1",
29
29
  "jquery-ui": "^1.14.1",
30
30
  "lodash.get": "^4.4.2",
@@ -33,23 +33,23 @@
33
33
  "react-dom": "17.0.2",
34
34
  "select2": "4.0.13",
35
35
  "select2-bootstrap-5-theme": "^1.3.0",
36
- "whatwg-fetch": "3.6.2"
36
+ "whatwg-fetch": "3.6.20"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@eslint/js": "^9.32.0",
40
- "autoprefixer": "^10.4.20",
40
+ "autoprefixer": "^10.4.22",
41
41
  "copy-webpack-plugin": "^13.0.1",
42
42
  "css-loader": "^7.1.2",
43
43
  "eslint": "^9.32.0",
44
44
  "eslint-plugin-import": "^2.32.0",
45
45
  "globals": "^16.3.0",
46
- "mini-css-extract-plugin": "^2.9.2",
46
+ "mini-css-extract-plugin": "^2.9.4",
47
47
  "npm-run-all2": "^8.0.4",
48
- "postcss": "^8.5.1",
48
+ "postcss": "^8.5.6",
49
49
  "postcss-loader": "^8.1.1",
50
50
  "prettier": "^3.5.3",
51
51
  "sass": "^1.84.0",
52
- "sass-loader": "^16.0.4",
52
+ "sass-loader": "^16.0.6",
53
53
  "webpack": "^5.98.0",
54
54
  "webpack-cli": "^6.0.1"
55
55
  },
@@ -0,0 +1,132 @@
1
+ const ITEM_CHECKBOX_SELECTOR = 'input[type="checkbox"][name="pk"]';
2
+ const TOGGLE_CHECKBOX_SELECTOR = 'input[type="checkbox"].toggle';
3
+ const SELECT_ALL_BOX_SELECTOR = '#select_all_box';
4
+ const SELECT_ALL_CHECKBOX_SELECTOR = '#select_all';
5
+
6
+ /**
7
+ * Set checkbox `checked` state while dispatching proper events. Do nothing if `checkbox.checked` state is already the
8
+ * same as passed `checked` parameter.
9
+ * @param {HTMLInputElement} checkbox - Checkbox HTML element.
10
+ * @param {boolean} checked - `checked` state to be set.
11
+ * @returns {void} Do not return any value, set given HTML element state and dispatch `'change'` and `'input'` events.
12
+ */
13
+ const setChecked = (checkbox, checked) => {
14
+ if (checkbox.checked !== checked) {
15
+ checkbox.checked = checked;
16
+ // Defer dispatching events with `setTimeout` to prevent handling intermediate states during sequential updates.
17
+ setTimeout(() => {
18
+ checkbox.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
19
+ checkbox.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
20
+ });
21
+ }
22
+ };
23
+
24
+ /**
25
+ * Initialize custom logic handlers for `class="toggle ..."`, `name="pk"` and `id="select-all" checkboxes.
26
+ * @returns {void} Do not return any value, just initialize proper custom logic handlers for specific checkboxes.
27
+ */
28
+ export const initializeCheckboxes = () => {
29
+ // Track the last selected checkbox index for range selection.
30
+ let lastSelectedIndex = null;
31
+
32
+ /*
33
+ * `onClick` and `onInput` event handlers both act on the same checkbox elements, but in slightly different scenarios:
34
+ * 1. `onClick` is specifically tied to manual user interaction and is called only when user directly interacts with
35
+ * checkbox element. It handles:
36
+ * 1.1. Checking/unchecking all individual checkboxes in the table when "toggle" checkbox is clicked.
37
+ * 1.2. Shift-click range selection for individual checkboxes in the table.
38
+ * 2. `onInput` is called after checkbox state is changed, regardless of the change being triggered programmatically
39
+ * or by manual user interaction. It handles:
40
+ * 2.1. Showing/hiding "select all" box when "toggle" checkbox is checked/unchecked.
41
+ * 2.2. Checking/unchecking "toggle" checkbox based on the collective state of individual checkboxes.
42
+ * 2.3. Enabling/disabling buttons in "select all" box based on its checkbox state.
43
+ */
44
+ const onClick = (event) => {
45
+ // "Toggle" checkbox for object lists (PK column). Notice distinction between handling `'click'` and `'input'` events.
46
+ const toggleCheckbox = event.target.closest(TOGGLE_CHECKBOX_SELECTOR);
47
+ if (toggleCheckbox) {
48
+ const isChecked = toggleCheckbox.checked;
49
+
50
+ // Check/uncheck all PK column checkboxes in the table.
51
+ toggleCheckbox
52
+ .closest('table')
53
+ .querySelectorAll(`${ITEM_CHECKBOX_SELECTOR}:not(.visually-hidden)`)
54
+ .forEach((checkbox) => setChecked(checkbox, isChecked));
55
+
56
+ // Reset last selected index when using toggle all
57
+ lastSelectedIndex = null;
58
+ }
59
+
60
+ // Individual row item checkbox in object lists (PK column). Notice distinction between handling `'click'` and `'input'` events.
61
+ const itemCheckbox = event.target.closest(ITEM_CHECKBOX_SELECTOR);
62
+ if (itemCheckbox) {
63
+ const table = itemCheckbox.closest('table');
64
+ const allCheckboxes = [...table.querySelectorAll(`${ITEM_CHECKBOX_SELECTOR}:not(.visually-hidden)`)];
65
+ const currentIndex = allCheckboxes.indexOf(itemCheckbox);
66
+
67
+ // Handle shift-click for range selection/deselection in PK column.
68
+ if (event.shiftKey && lastSelectedIndex !== null) {
69
+ // Create range from previous click to current click
70
+ const startIndex = Math.min(lastSelectedIndex, currentIndex);
71
+ const endIndex = Math.max(lastSelectedIndex, currentIndex);
72
+
73
+ // Use the clicked item's new state for entire range
74
+ const shouldSelect = itemCheckbox.checked;
75
+
76
+ // Apply to entire range
77
+ allCheckboxes.slice(startIndex, endIndex + 1).forEach((checkbox) => setChecked(checkbox, shouldSelect));
78
+ }
79
+
80
+ // Always update anchor to current click (normal click or shift+click)
81
+ lastSelectedIndex = currentIndex;
82
+ }
83
+ };
84
+
85
+ const onInput = (event) => {
86
+ // "Toggle" checkbox for object lists (PK column). Notice distinction between handling `'click'` and `'input'` events.
87
+ const toggleCheckbox = event.target.closest(TOGGLE_CHECKBOX_SELECTOR);
88
+ if (toggleCheckbox) {
89
+ const isChecked = toggleCheckbox.checked;
90
+
91
+ // Show/hide the select all objects form that contains the bulk action buttons.
92
+ const selectAllBox = document.querySelector(SELECT_ALL_BOX_SELECTOR);
93
+ selectAllBox?.classList.toggle('visually-hidden', !isChecked);
94
+
95
+ if (selectAllBox && !isChecked) {
96
+ const selectAll = document.querySelector(SELECT_ALL_CHECKBOX_SELECTOR);
97
+ if (selectAll) {
98
+ setChecked(selectAll, false);
99
+ }
100
+ }
101
+ }
102
+
103
+ // Individual row item checkbox in object lists (PK column). Notice distinction between handling `'click'` and `'input'` events.
104
+ const itemCheckbox = event.target.closest(ITEM_CHECKBOX_SELECTOR);
105
+ if (itemCheckbox) {
106
+ const table = itemCheckbox.closest('table');
107
+ const allCheckboxes = [...table.querySelectorAll(`${ITEM_CHECKBOX_SELECTOR}:not(.visually-hidden)`)];
108
+
109
+ // Check or uncheck the "toggle" checkbox if all items are checked or any item is unchecked, respectively.
110
+ const tableToggleCheckbox = table.querySelector(TOGGLE_CHECKBOX_SELECTOR);
111
+ if (tableToggleCheckbox) {
112
+ const hasUnchecked = allCheckboxes.some((checkbox) => !checkbox.checked);
113
+ setChecked(tableToggleCheckbox, !hasUnchecked);
114
+ }
115
+ }
116
+
117
+ // `selectAll` is the checkbox that selects all objects.
118
+ const selectAll = event.target.closest(SELECT_ALL_CHECKBOX_SELECTOR);
119
+ // `selectAllBox` is the form bulk action buttons container.
120
+ const selectAllBox = event.target.closest(SELECT_ALL_BOX_SELECTOR);
121
+ if (selectAll && selectAllBox) {
122
+ // If the `selectAll` checkbox is checked, enable all form bulk action buttons.
123
+ const isChecked = selectAll.checked;
124
+ selectAllBox.querySelectorAll('button').forEach((button) => {
125
+ button.disabled = !isChecked;
126
+ });
127
+ }
128
+ };
129
+
130
+ document.addEventListener('click', onClick);
131
+ document.addEventListener('input', onInput);
132
+ };
@@ -34,8 +34,10 @@ window.jQuery = jQuery;
34
34
  window.$ = window.jQuery;
35
35
 
36
36
  import 'jquery-ui';
37
+ import 'jquery-ui/ui/widgets/sortable.js';
37
38
  import 'select2';
38
39
 
40
+ import { initializeCheckboxes } from './checkbox.js';
39
41
  import { initializeCollapseToggleAll } from './collapse.js';
40
42
  import { initializeDraggable } from './draggable.js';
41
43
  import { initializeDrawers } from './drawer.js';
@@ -53,6 +55,7 @@ document.addEventListener('DOMContentLoaded', () => {
53
55
  // History
54
56
  loadState();
55
57
  window.nb.history = { saveState };
58
+
56
59
  // Tooltips
57
60
  // https://getbootstrap.com/docs/5.3/components/tooltips/#enable-tooltips
58
61
  [...document.querySelectorAll('[data-bs-toggle="tooltip"]')].forEach((tooltip) => new bootstrap.Tooltip(tooltip));
@@ -60,6 +63,9 @@ document.addEventListener('DOMContentLoaded', () => {
60
63
  // Sidenav
61
64
  initializeSidenav();
62
65
 
66
+ // Checkbox
67
+ window.nb.checkbox = { initializeCheckboxes };
68
+
63
69
  // Collapse
64
70
  initializeCollapseToggleAll();
65
71
 
@@ -134,80 +134,76 @@ const initializeDynamicChoiceSelection = (context, dropdownParent = null) => {
134
134
  const content_type = element.getAttribute('data-contenttype');
135
135
 
136
136
  // Attach any extra query parameters
137
- const extra_query_parameters = Object.fromEntries(
138
- [...element.attributes]
139
- .filter((attribute) => attribute.name.includes('data-query-param-'))
140
- .flatMap((attribute) => {
141
- const [, param_name] = attribute.name.split('data-query-param-');
142
-
143
- const values = (() => {
144
- try {
145
- return JSON.parse(attribute.value);
146
- // eslint-disable-next-line no-unused-vars
147
- } catch (exception) {
148
- return [];
149
- }
150
- })();
137
+ const extra_query_parameters_array = [...element.attributes]
138
+ .filter((attribute) => attribute.name.includes('data-query-param-'))
139
+ .flatMap((attribute) => {
140
+ const [, param_name] = attribute.name.split('data-query-param-');
141
+
142
+ const values = (() => {
143
+ try {
144
+ return JSON.parse(attribute.value);
145
+ // eslint-disable-next-line no-unused-vars
146
+ } catch (exception) {
147
+ return [];
148
+ }
149
+ })();
150
+
151
+ return values.flatMap((value) => {
152
+ const has_ref_field = value.startsWith('$');
153
+
154
+ // Referencing the value of another form field.
155
+ const ref_field = has_ref_field
156
+ ? (() => {
157
+ const name = value.slice(1);
158
+
159
+ if (element.id.includes('id_form-')) {
160
+ const [id_prefix] = element.id.match(/id_form-[0-9]+-/i, '');
161
+ return document.querySelector(`#${id_prefix}${name}`);
162
+ }
163
+
164
+ /*
165
+ * If the element is in a table row with a class containing "dynamic-formset" we need to find the
166
+ * reference field in the same row.
167
+ */
168
+ if (element.closest('tr')?.classList.contains('dynamic-formset')) {
169
+ return element.closest('tr').querySelector(`select[id*="${name}"]`);
170
+ }
171
+
172
+ return document.querySelector(`#id_${name}`);
173
+ })()
174
+ : null;
175
+
176
+ const ref_field_value = ref_field
177
+ ? (() => {
178
+ const field_value = getValue(ref_field);
179
+ const style = window.getComputedStyle(ref_field);
151
180
 
152
- return values.flatMap((value) => {
153
- const has_ref_field = value.startsWith('$');
154
-
155
- // Referencing the value of another form field.
156
- const ref_field = has_ref_field
157
- ? (() => {
158
- const name = value.slice(1);
159
-
160
- if (element.id.includes('id_form-')) {
161
- const [id_prefix] = element.id.match(/id_form-[0-9]+-/i, '');
162
- return document.querySelector(`#${id_prefix}${name}`);
163
- }
164
-
165
- /*
166
- * If the element is in a table row with a class containing "dynamic-formset" we need to find the
167
- * reference field in the same row.
168
- */
169
- if (element.closest('tr')?.classList.contains('dynamic-formset')) {
170
- return element.closest('tr').querySelector(`select[id*="${name}"]`);
171
- }
172
-
173
- return document.querySelector(`#id_${name}`);
174
- })()
175
- : null;
176
-
177
- const ref_field_value = ref_field
178
- ? (() => {
179
- const field_value = getValue(ref_field);
180
- const style = window.getComputedStyle(ref_field);
181
-
182
- if (field_value && style.opacity !== '0' && style.visibility !== 'hidden') {
183
- return field_value;
184
- }
185
-
186
- if (ref_field.getAttribute('required') && ref_field.getAttribute('data-null-option')) {
187
- return 'null';
188
- }
189
-
190
- return undefined;
191
- })()
192
- : null;
193
-
194
- const param_value = has_ref_field ? ref_field_value : value;
195
- return param_value !== null && param_value !== undefined
196
- ? [[param_name, ref_field_value || value]]
197
- : [];
198
- });
199
- }),
200
- );
201
-
202
- const parameters = {
203
- depth: String(depth),
204
- limit: String(limit),
205
- offset: String(offset),
206
- ...(api_version ? { api_version } : undefined),
207
- ...(content_type ? { content_type } : undefined),
208
- ...(q ? { [search_field]: q } : undefined),
209
- ...extra_query_parameters,
210
- };
181
+ if (field_value && style.opacity !== '0' && style.visibility !== 'hidden') {
182
+ return field_value;
183
+ }
184
+
185
+ if (ref_field.getAttribute('required') && ref_field.getAttribute('data-null-option')) {
186
+ return 'null';
187
+ }
188
+
189
+ return undefined;
190
+ })()
191
+ : null;
192
+
193
+ const param_value = has_ref_field ? ref_field_value : value;
194
+ return param_value !== null && param_value !== undefined ? [[param_name, ref_field_value || value]] : [];
195
+ });
196
+ });
197
+
198
+ const parameters = [
199
+ ['depth', String(depth)],
200
+ ['limit', String(limit)],
201
+ ['offset', String(offset)],
202
+ ...(api_version ? [['api_version', api_version]] : []),
203
+ ...(content_type ? [['content_type', content_type]] : []),
204
+ ...(q ? [[search_field, q]] : []),
205
+ ...extra_query_parameters_array,
206
+ ];
211
207
 
212
208
  // This will handle params with multiple values (i.e. for list filter forms).
213
209
  return new URLSearchParams(parameters).toString();
@@ -1,4 +1,5 @@
1
- import { getCookie, removeCookie, setCookie } from './cookie.js';
1
+ import { Modal } from 'bootstrap';
2
+ import { getCookie, setCookie } from './cookie.js';
2
3
 
3
4
  const THEME_MODAL_ID = 'theme_modal';
4
5
 
@@ -6,70 +7,120 @@ const THEME_DARK = 'dark';
6
7
  const THEME_LIGHT = 'light';
7
8
  const THEME_SYSTEM = 'system';
8
9
 
10
+ const THEME_CHOICE_COOKIE_NAME = 'theme_choice';
11
+ const THEME_DETERMINED_COOKIE_NAME = 'theme';
12
+
9
13
  /**
10
- * Get preferred system color scheme.
14
+ * Check if given `theme_choice` is a valid theme choice, i.e. `'dark'`, `'light'`, or `'system'`:
15
+ * - 'dark' for always dark mode
16
+ * - 'light' for always light mode
17
+ * - 'system' to follow the system/browser preference, getPreferredColorScheme() will determine actual theme
18
+ * @param {string} theme_choice - Choice in question.
19
+ * @returns {boolean} `true` is given `theme_choice` is a valid Nautobot theme, `false` otherwise.
20
+ */
21
+ const isValidThemeChoice = (theme_choice) => [THEME_DARK, THEME_LIGHT, THEME_SYSTEM].includes(theme_choice);
22
+
23
+ /**
24
+ * Get preferred system color scheme from browser/OS settings.
11
25
  * @returns {('dark'|'light')} Preferred system color scheme.
12
26
  */
13
- const getPreferredColorScheme = () =>
27
+ const determineBrowserPreference = () =>
14
28
  window.matchMedia?.(`(prefers-color-scheme: ${THEME_DARK})`).matches ? THEME_DARK : THEME_LIGHT;
15
29
 
16
30
  /**
17
- * Check if given `theme` is a valid Nautobot theme, i.e. `'dark'`, `'light'`, or `'system'`.
18
- * @param {string} theme - Theme in question.
19
- * @returns {boolean} `true` is given `theme` is a valid Nautobot theme, `false` otherwise.
31
+ * Determine the effective theme to be used.
32
+ * @returns {('dark'|'light')} The effective theme.
20
33
  */
21
- const isValidTheme = (theme) => theme === THEME_DARK || theme === THEME_LIGHT || theme === THEME_SYSTEM;
34
+ const determineTheme = () => {
35
+ const current_theme_choice = getCookie(THEME_CHOICE_COOKIE_NAME);
36
+ if ([THEME_DARK, THEME_LIGHT].includes(current_theme_choice)) {
37
+ // An explicit choice of dark or light mode.
38
+ return current_theme_choice;
39
+ }
40
+ // Follow the system/browser preference by default ('system' choice or invalid choice).
41
+ return determineBrowserPreference();
42
+ };
22
43
 
23
44
  /**
24
- * Automatically detect Nautobot theme. It is derived from `cookie` or `localStorage` if set manually, and falls back to
25
- * preferred system color scheme by default.
26
- * @returns {('dark'|'light'|'system')} Detected Nautobot theme.
45
+ * Persist the user's theme choice in cookie.
46
+ * @param {('dark'|'light'|'system')} theme - The user's theme choice.
47
+ * @returns {void} Do not return any value
27
48
  */
28
- const detectTheme = () => {
29
- const cookieTheme = getCookie('theme');
30
- if (isValidTheme(cookieTheme)) {
31
- return cookieTheme;
49
+ const persistThemeChoice = (theme) => {
50
+ if (isValidThemeChoice(theme)) {
51
+ setCookie(THEME_CHOICE_COOKIE_NAME, theme, { 'max-age': 31536000, path: '/' }); // 1 year
32
52
  }
53
+ };
33
54
 
34
- const localStorageTheme = window.localStorage?.getItem('theme');
35
- if (isValidTheme(localStorageTheme)) {
36
- return localStorageTheme;
55
+ /**
56
+ * Handle syntax highlighter theme change.
57
+ * @param {('dark'|'light')} theme - The effective theme.
58
+ * @returns {void} Do not return any value
59
+ */
60
+ const handleSyntaxHighlighterThemeChange = (theme) => {
61
+ // Since the syntax highlighter stylesheet is loaded via a <link> tag, we need to swap out the href to change themes.
62
+ // The best fix would be to have both stylesheets loaded and use media queries to switch between them, similar to how we do for the main Nautobot theme.
63
+ const highlighterLinks = document.querySelectorAll('link[rel="stylesheet"][href*="github"]');
64
+ if (highlighterLinks.length === 1) {
65
+ // We send two stylesheets on the initial page with media queries, but after that only one is present which we need to swap out.
66
+ const [syntaxLinkElement] = highlighterLinks;
67
+ const css_file = theme === THEME_DARK ? 'github-dark.min.css' : 'github.min.css';
68
+ syntaxLinkElement.href = syntaxLinkElement.href.replace(/github(-dark)?\.min\.css/, css_file);
37
69
  }
70
+ };
38
71
 
39
- return THEME_SYSTEM;
72
+ /**
73
+ * Handle Echarts theme change.
74
+ * @param {('dark'|'light')} theme - The effective theme.
75
+ * @returns {void} Do not return any value
76
+ */
77
+ const handleEchartsThemeChange = (theme) => {
78
+ // If using Echarts, we need to update the theme there as well.
79
+ const echart_instances = document.querySelectorAll('div[_echarts_instance_]');
80
+ echart_instances?.forEach((instance) => {
81
+ const options = JSON.parse(document.getElementById(`echarts-config-${instance.id}`).textContent);
82
+ const colors = Array.isArray(options.color)
83
+ ? options.color.map((colorObj) => colorObj?.[theme] || colorObj?.light || colorObj)
84
+ : options.color;
85
+ window.echarts.getInstanceByDom(instance)?.setOption({
86
+ color: colors,
87
+ darkMode: theme === THEME_DARK,
88
+ });
89
+ });
40
90
  };
41
91
 
42
92
  /**
43
93
  * Set Nautobot theme.
44
- * @param {('dark'|'light'|'system')} theme - Nautobot theme to be set.
45
- * @param {{ manual?: boolean }} [options] - Setter function options object. Currently supported option is `manual`.
94
+ * @param {('dark'|'light'|'system'|null)} theme - Nautobot theme to be set. If `null`, determine theme based on existing user choice or system preference.
46
95
  * @returns {void} Do not return any value, set given `theme` and save it into a persistent store if `manual` is `true`.
47
96
  */
48
- const setTheme = (theme, options) => {
49
- const isManual = Boolean(options?.manual);
97
+ const setTheme = (theme = null) => {
98
+ if (theme !== null && isValidThemeChoice(theme)) {
99
+ // An explicit theme was provided, so we persist the choice.
100
+ persistThemeChoice(theme);
101
+ }
102
+ const current_theme_choice = getCookie(THEME_CHOICE_COOKIE_NAME); // The user's choice for the purpose of button highlighting.
103
+ const determined_theme = determineTheme(); // The effective theme to be applied.
104
+
105
+ // Persist the determined theme for server-side rendering purposes.
106
+ setCookie(THEME_DETERMINED_COOKIE_NAME, determined_theme, { 'max-age': 31536000, path: '/' }); // 1 year
50
107
 
51
108
  const modal = document.getElementById(THEME_MODAL_ID);
52
109
  const buttons = modal?.querySelectorAll('button[data-nb-theme]') ?? [];
53
110
 
54
111
  buttons.forEach((button) =>
55
112
  ['border', 'border-primary'].forEach((className) =>
56
- button.classList.toggle(className, button.dataset.nbTheme === theme),
113
+ button.classList.toggle(className, button.dataset.nbTheme === current_theme_choice),
57
114
  ),
58
115
  );
59
116
 
60
- const bsTheme = theme === THEME_SYSTEM ? getPreferredColorScheme() : theme;
61
- document.documentElement.dataset.theme = bsTheme;
62
- document.documentElement.dataset.bsTheme = bsTheme;
117
+ document.documentElement.dataset.theme = determined_theme;
118
+ document.documentElement.dataset.bsTheme = determined_theme;
63
119
 
64
- if (theme === THEME_SYSTEM) {
65
- removeCookie('theme');
66
- window.localStorage?.removeItem('theme');
67
- } else if (isManual) {
68
- setCookie('theme', theme);
69
- window.localStorage?.setItem('theme', theme);
70
- }
120
+ handleSyntaxHighlighterThemeChange(determined_theme);
121
+ handleEchartsThemeChange(determined_theme);
71
122
 
72
- if (theme === THEME_DARK || (theme === THEME_SYSTEM && bsTheme === THEME_DARK)) {
123
+ if (determined_theme === THEME_DARK) {
73
124
  [...document.getElementsByTagName('object')].forEach((object) => {
74
125
  object.addEventListener('load', (event) => {
75
126
  if (event.target.contentDocument) {
@@ -84,20 +135,59 @@ const setTheme = (theme, options) => {
84
135
  });
85
136
  });
86
137
  }
138
+ };
87
139
 
88
- if (isManual) {
89
- document.location.reload();
140
+ const handleThemeChoiceUpgradeAndSetDefault = () => {
141
+ /**
142
+ * We need to handle the "upgrade" scenario:
143
+ * - Prior to Nautobot 3.0, the `theme` cookie was a Session cookie so not persisted.
144
+ * - localStorage was where a user's choice was "stored"
145
+ *
146
+ * In Nautobot 3.0+, we will do away with localStorage and instead move the user's choice to a persistent cookie, `theme_choice`.
147
+ * We will also keep the `theme` cookie as the "determined" theme for server-side rendering purposes.
148
+ * Generally we shouldn't need this as good media queries should handle it, but it's here for completeness.
149
+ * For example, some SVG renderings on the server may need to know the theme.
150
+ *
151
+ * To handle this upgrade scenario, we will:
152
+ * 1. Check if `theme_choice` cookie exists. If it does, we assume the user has already "upgraded" and do nothing.
153
+ * 2. If `theme_choice` cookie does not exist, we check localStorage for the user's theme choice.
154
+ * 3. If localStorage has a valid theme choice, we persist it to the `theme_choice` cookie.
155
+ * 4. Finally, we remove the theme from localStorage to complete the migration.'
156
+ *
157
+ * We can remove/refactor this in 4.0 for certain, but likely in 3.1+.
158
+ *
159
+ * In that scenario, if the theme_choice cookie is missing, we can default to 'system' without checking localStorage.
160
+ */
161
+
162
+ const themeChoiceCookie = getCookie(THEME_CHOICE_COOKIE_NAME);
163
+ if (!isValidThemeChoice(themeChoiceCookie)) {
164
+ // If theme_choice cookie does not exist or is invalid, check localStorage
165
+ const localStorageTheme = window.localStorage?.getItem('theme');
166
+
167
+ // If localStorage theme is valid, we use it, else we default to 'system';
168
+ // Note: we always removed the localStorage to imply the 'system' choice so technically we should only check for 'dark' or 'light' here, but we validate just in case.
169
+ const theme_choice_to_set = isValidThemeChoice(localStorageTheme) ? localStorageTheme : THEME_SYSTEM;
170
+
171
+ setCookie(THEME_CHOICE_COOKIE_NAME, theme_choice_to_set, { 'max-age': 31536000, path: '/' }); // 1 year
90
172
  }
173
+ // Else: If theme_choice cookie exists and is valid, we don't need to do any migration.
174
+ // Regardless, we remove localStorage to complete migration, on the off chance it still exists.
175
+ window.localStorage?.removeItem('theme');
91
176
  };
92
177
 
93
178
  export const initializeTheme = () => {
94
179
  const modal = document.getElementById(THEME_MODAL_ID);
95
180
  const buttons = modal?.querySelectorAll('button[data-nb-theme]') ?? [];
96
181
 
97
- setTheme(detectTheme());
98
- window.matchMedia(`(prefers-color-scheme: ${THEME_DARK})`).addEventListener('change', () => setTheme(detectTheme()));
182
+ handleThemeChoiceUpgradeAndSetDefault();
183
+ setTheme();
184
+
185
+ window.matchMedia(`(prefers-color-scheme: ${THEME_DARK})`).addEventListener('change', () => setTheme());
99
186
 
100
- const onClick = (event) => setTheme(event.currentTarget.dataset.nbTheme, { manual: true });
187
+ const onClick = (event) => {
188
+ setTheme(event.currentTarget.dataset.nbTheme);
189
+ Modal.getInstance(modal).hide();
190
+ };
101
191
  buttons.forEach((button) => button.addEventListener('click', onClick));
102
192
 
103
193
  return () => buttons.forEach((button) => button.removeEventListener('click', onClick));
@@ -114,6 +114,7 @@ $dark-border-subtle-dark: $gray-05-dark;
114
114
  /* Options */
115
115
  $enable-shadows: true;
116
116
  $enable-caret: false;
117
+ $enable-smooth-scroll: false;
117
118
  $enable-negative-margins: true;
118
119
 
119
120
  $prefix: bs-;
@@ -419,6 +420,8 @@ $btn-close-filter-dark: invert(100%); /* $black-0-dark: #ffffff; */
419
420
  $btn-close-white-filter: $btn-close-filter-dark;
420
421
 
421
422
  /* Code */
423
+ $code-font-size: $font-size-base;
424
+
422
425
  $kbd-padding-y: map.get($spacers, 1);
423
426
  $kbd-padding-x: map.get($spacers, 4);
424
427
  $kbd-color: var(--#{$prefix}secondary-color);
@@ -775,11 +778,13 @@ button {
775
778
 
776
779
  > thead:not(caption) > tr > th {
777
780
  background-color: var(--#{$prefix}tertiary-bg);
778
- color: var(--#{$prefix}secondary-color);
781
+ color: var(--#{$prefix}tertiary-color);
779
782
  position: relative;
780
783
 
781
784
  &.nb-actionable,
782
785
  &.orderable {
786
+ color: var(--#{$prefix}secondary-color);
787
+
783
788
  a,
784
789
  button {
785
790
  &:empty {
@@ -1454,9 +1459,14 @@ textarea {
1454
1459
  }
1455
1460
 
1456
1461
  /* Code */
1462
+ :not(pre) > code:not(.hljs) { /* Select only `code` which are not immediate children of `pre`, that is _inline code_. */
1463
+ @extend kbd;
1464
+ }
1465
+
1457
1466
  pre:not(:has(code.hljs)) { /* Do not include highlight.js in this rule because it has its own set of styles. */
1458
1467
  background: var(--#{$prefix}secondary-bg);
1459
1468
  border-radius: $border-radius;
1469
+ color: $kbd-color;
1460
1470
  padding-block: map.get($spacers, 8);
1461
1471
  padding-inline: map.get($spacers, 10);
1462
1472
  }
@@ -20,7 +20,7 @@
20
20
 
21
21
  <div class="card">
22
22
  <div class="card-header"><strong>Phase 1 Policy Assignments</strong></div>
23
- <div class="card-body">
23
+ <div class="card-body overflow-auto">
24
24
  {% if vpn_phase1_policies.errors %}
25
25
  <div class="text-danger">
26
26
  Please correct the error(s) below:
@@ -72,7 +72,7 @@
72
72
  </div>
73
73
  <div class="card">
74
74
  <div class="card-header"><strong>Phase 2 Policy Assignments</strong></div>
75
- <div class="card-body">
75
+ <div class="card-body overflow-auto">
76
76
  {% if vpn_phase2_policies.errors %}
77
77
  <div class="text-danger">
78
78
  Please correct the error(s) below: