exb-mdprint 1.0.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.
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # Markdown Print Widget
2
+
3
+ This widget uses the simplicity of the Markdown language, combined with a simple parser, to create a wicked fast feature report generation tool. The markdown print widget allows the GIS builder to create a feature report using similar syntax to the Survey123 Feature Report syntax, providing a recognizable user experience when developing your feature reports. Some of the more complex features of Esri's Feature Reports have not been replicated (Like conditional formatting and map embeds). However, what you may lose in the way of "nice to have" features of feature reports, you gain in printing speed, reliability, and end-user modification.
4
+
5
+ ## User Experience
6
+
7
+ This widget aims to make the user experience as simple as possible. The builder will configure potentially multiple templates, which the end user can choose from. When the user selects a feature (Or multiple features), the print button will become active, and an indicator will appear with the number of features selected for that template. By default, the template editor is hidden, but if the user is advanced, and wants to edit the way the template would be generated, they can open the template markdown or css and edit it in their own session.
8
+
9
+ When prints are made, there are no service calls, no downtime, just reliable markdown to html formatting, printing directly in browser. No server is receiving your print job, and the only service that is needed is for the data to load into your application for you to select. This means speed, and reliability, which is paramount for workflows like a check-in process, large document generation for mailing, record generation for government documentation requirements, etc.
10
+
11
+ ## Builder Experience
12
+
13
+ The builder shouldn't have a difficult time formatting this widget. Since Markdown is used, it simplifies the formatting requirements, meaning that you don't have to fiddle around with a table in word, or ensure that all of your text is properly slammed together. Markdown also supports html tags, so things like images can still be embedded into the generated reports, meaning logos can be used! Since CSS is also used for the formatting, the sky is the limit in terms of the styling that is desired. Using other sites, like [https://markdowntohtml.com/](https://markdowntohtml.com/), you can quickly see results for generating your markdown and css templates. If you're looking for css inspiration (Or just want something to start with), I highly recommend checking out Jason M's GitHub collection of a few css stylesheets that is publicly available, along with a quick [visualizer](https://jasonm23.github.io/markdown-css-themes/) of the themes in action.
14
+
15
+ ## Credits
16
+
17
+ This widget would not be possible without a few external libraries.
18
+
19
+ - marked: Used for converting markdown into HTML. This library is doing the bulk of the work.
20
+ - DOMPurify: Used for taking the html generated by marked, and cleaning it up, preventing someone from injecting malicious code into a feature service, which could be exploited when a print is made on it.
21
+ - print-js: Used for taking the generated HTML, and opening a print dialog with a well formatted document, all in-browser.
22
+
23
+ These external libraries power ~50% of the functionality of this widget. The user interface, and builder capabilities are done through Jimu-ui, and Calcite Components, and the parsing functionality to convert feature service data into the feature report is where the bulk of the effort actually put into this widget went.
package/config.json ADDED
@@ -0,0 +1 @@
1
+ {}
package/doc/index.html ADDED
@@ -0,0 +1,42 @@
1
+ <head>
2
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
3
+
4
+ <!--Import Calcite Stylesheet via CDN. This is easier than having to create react stuff for each static page-->
5
+ <script
6
+ type="module"
7
+ src="https://js.arcgis.com/calcite-components/3.2.1/calcite.esm.js"
8
+ ></script>
9
+
10
+ <!--Import Noto Sans so that the website draws properly-->
11
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
12
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
13
+ <link
14
+ href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wdth,wght@0,62.5..100,100..900;1,62.5..100,100..900&display=swap"
15
+ rel="stylesheet"
16
+ />
17
+
18
+ <!--Reference component stylesheet-->
19
+ <link rel="stylesheet" href="doc/styles.css" />
20
+
21
+ <!--Webpage Title-->
22
+ <title>Markdown Print Help</title>
23
+ </head>
24
+ <body>
25
+ <div class="header noto-sans-heavy">
26
+ <h1>Print Widget</h1>
27
+ </div>
28
+ <p>
29
+ This widget is designed to print simple feature reports using
30
+ <a href="https://www.markdownguide.org/basic-syntax/">markdown</a>
31
+ configuration. The markdown configured in the settings can include fields
32
+ from the datasource, wrapped in ${}. You can also perform some simple
33
+ formatting on date or number fields, using the following syntax:<br />
34
+ Date: ${dateField | "M/D/YY h:mm A"} => 2/15/26 4:23 PM<br />
35
+ Number: ${numField | "#,###.00"} => 23,456.78<br /><br />
36
+ The formatting string uses Arcade syntax, similar to the syntax in the
37
+ <a
38
+ href="https://developers.arcgis.com/arcade/function-reference/text_functions/#text"
39
+ >Text() Arcade function</a
40
+ >
41
+ </p>
42
+ </body>
package/doc/styles.css ADDED
@@ -0,0 +1,95 @@
1
+ :root {
2
+ font-family: "Noto Sans", sans-serif;
3
+
4
+ --calcite-sans-family: 'Noto Sans', Arial, Helvetica, sans-serif;
5
+ }
6
+
7
+ body,
8
+ .calcite-mode-dark,
9
+ .calcite-mode-light {
10
+ font-family: 'Noto Sans', Arial, Helvetica, sans-serif !important;
11
+ }
12
+
13
+ .action-bar {
14
+ --calcite-action-bar-items-space: 15px;
15
+ --calcite-action-bar-size: 100%
16
+ }
17
+
18
+ .menu-button {
19
+ height: 30px;
20
+ width: 30px;
21
+ padding-left: 15px;
22
+ padding-right: 15px;
23
+ padding-bottom: 15px;
24
+ }
25
+
26
+ calcite-button {
27
+ --calcite-button-corner-radius: 5em;
28
+ --calcite-button-text-color: '#ffffff';
29
+ --calcite-button-icon-color: '#ffffff'
30
+ }
31
+
32
+ html {
33
+ font-size: 120%
34
+ /* Page scaling was too small */
35
+ }
36
+
37
+ body {
38
+ background-color: #212121;
39
+ color: #ffffff;
40
+ }
41
+
42
+ main {
43
+ width: calc(500px + 20%);
44
+ max-width: 90%;
45
+ margin: auto;
46
+ padding: 20px;
47
+ }
48
+
49
+ footer {
50
+ text-align: right;
51
+ font-size: smaller;
52
+ padding: 15px;
53
+ }
54
+
55
+ .sub-header {
56
+ color: #ffffff;
57
+ background-color: #212121;
58
+ padding: 10px;
59
+ text-align: center;
60
+ }
61
+
62
+ .header {
63
+ color: #ffffff;
64
+ background-color: #212121;
65
+ text-align: center;
66
+ font-optical-sizing: auto;
67
+ font-weight: 700;
68
+ font-style: normal;
69
+ font-variation-settings: "wdth" 75;
70
+ }
71
+
72
+ .header h1 {
73
+ font-size: 50px;
74
+ }
75
+
76
+ .header h2 {
77
+ font-size: 30px;
78
+ }
79
+
80
+ .header h3 {
81
+ font-size: 20px;
82
+ }
83
+
84
+ a:link {
85
+ color: var(--calcite-color-text-link) /* Unvisited link color */
86
+ }
87
+
88
+ a:visited {
89
+ color: #DC90EE; /* Visited link color */
90
+ }
91
+
92
+ a:focus-visible {
93
+ outline: 2px solid var(--calcite-color-brand);
94
+ border-radius: 4px;
95
+ }
package/icon.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="208" height="128" viewBox="0 0 208 128"><rect width="198" height="118" x="5" y="5" ry="10" stroke="#000" stroke-width="10" fill="none"/><path d="M30 98V30h20l20 25 20-25h20v68H90V59L70 84 50 59v39zm125 0l-30-33h20V30h20v35h20z"/></svg>
package/manifest.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "MDPrint",
3
+ "label": "MDPrint",
4
+ "type": "widget",
5
+ "version": "1.19.0",
6
+ "exbVersion": "1.19.0",
7
+ "author": "Lucius Creamer",
8
+ "description": "This widget allows users to print feature reports from the ArcGIS Experience Builder, client-side.",
9
+ "dependency": ["jimu-arcgis"],
10
+ "license": "MIT",
11
+ "translatedLocales": ["en"],
12
+ "defaultSize": {
13
+ "width": 400,
14
+ "height": 800
15
+ }
16
+ }
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "exb-mdprint",
3
+ "version": "1.0.0",
4
+ "description": "A widget for printing a feature report client-side.",
5
+ "license": "MIT",
6
+ "author": "Lucius Creamer",
7
+ "dependencies": {
8
+ "dompurify": "^3.3.1",
9
+ "marked": "^17.0.2",
10
+ "print-js": "^1.6.0"
11
+ },
12
+ "keywords": [
13
+ "exb-widget",
14
+ "experience-builder",
15
+ "exb"
16
+ ],
17
+ "repository": {
18
+ "url": "https://github.com/SunshineLuke90/widgets"
19
+ }
20
+ }
package/src/config.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { UseDataSource } from "jimu-core"
2
+ import type { ImmutableObject } from "seamless-immutable"
3
+
4
+ export interface PrintTemplate {
5
+ id: string
6
+ label: string
7
+ markdown: string
8
+ css: string
9
+ useDataSources?: UseDataSource[]
10
+ }
11
+
12
+ export interface Config {
13
+ PrintTemplates: PrintTemplate[]
14
+ }
15
+
16
+ export type IMConfig = ImmutableObject<Config>
@@ -0,0 +1,135 @@
1
+ import DOMPurify from "dompurify"
2
+ import { marked } from "marked"
3
+ import printJS from "print-js"
4
+
5
+ /**
6
+ A simple date formatting function that supports the same token formatting as ArcGIS Arcade, for example:
7
+
8
+ formatDate($feature.dateField, 'MMMMM D, YYYY') => January 1, 2024
9
+
10
+ formatDate($feature.dateField, 'MM/DD/YYYY') => 01/01/2024
11
+
12
+ formatDate($feature.dateField, 'YYYY-MM-DD HH:mm:ss') => 2024-01-01 13:00:00
13
+ */
14
+ const formatDate = (date: Date, format: string) => {
15
+ const map = {
16
+ MMMMM: date.toLocaleString("default", { month: "long" }),
17
+ MMM: date.toLocaleString("default", { month: "short" }),
18
+ MM: ("0" + (date.getMonth() + 1)).slice(-2),
19
+ M: date.getMonth() + 1,
20
+ DDDD: date.toLocaleString("default", { weekday: "long" }),
21
+ DDD: date.toLocaleString("default", { weekday: "short" }),
22
+ DD: ("0" + date.getDate()).slice(-2),
23
+ D: date.getDate(),
24
+ YYYY: date.getFullYear(),
25
+ YY: date.getFullYear().toString().slice(-2),
26
+ hh: ("0" + (date.getHours() % 12 || 12)).slice(-2),
27
+ h: date.getHours() % 12 || 12,
28
+ HH: ("0" + date.getHours()).slice(-2),
29
+ H: date.getHours(),
30
+ mm: ("0" + date.getMinutes()).slice(-2),
31
+ ss: ("0" + date.getSeconds()).slice(-2),
32
+ A: date.getHours() >= 12 ? "PM" : "AM"
33
+ }
34
+
35
+ return format.replace(
36
+ /MMMMM|MMM|MM|M|DDDD|DDD|DD|D|YYYY|YY|hh|h|HH|H|mm|ss|A/g,
37
+ (matched) => map[matched]
38
+ )
39
+ }
40
+
41
+ /**
42
+ A simple number formatting function that supports comma separators and fixed decimal places, slightly different than ArcGIS Arcade, for example:
43
+
44
+ formatNumber(1234567.89, '#,###.00') => 1,234,567.89
45
+
46
+ formatNumber(1234.5, '0.00') => 1234.50
47
+
48
+ formatNumber(1234.5678, '0.0') => 1234.6
49
+ */
50
+ const formatNumber = (num: number, format: string) => {
51
+ // Example: '#,###.00'
52
+ const hasComma = format.includes(",")
53
+ const decimalMatches = format.match(/\.(\d+)/)
54
+ const decimalPlaces = decimalMatches ? decimalMatches[1].length : 0
55
+
56
+ let result = num.toFixed(decimalPlaces)
57
+
58
+ if (hasComma) {
59
+ const parts = result.split(".")
60
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",")
61
+ result = parts.join(".")
62
+ }
63
+
64
+ return result
65
+ }
66
+
67
+ export const applyFormat = (value: any, format: string) => {
68
+ // If no format is provided, just return the raw value (or a string version)
69
+ if (!format) return value ?? ""
70
+
71
+ const isDateFormat = /[YMDhms]/.test(format)
72
+ const isNumberFormat = /[#0]/.test(format)
73
+
74
+ // 2. Route based on intent
75
+ if (isDateFormat) {
76
+ // Convert number/string to Date object first
77
+ const date = new Date(Number(value) || value)
78
+ return formatDate(date, format)
79
+ }
80
+
81
+ if (isNumberFormat) {
82
+ const num = parseFloat(value)
83
+ return formatNumber(num, format)
84
+ }
85
+
86
+ return value ?? "" // Fallback for strings or unknown types
87
+ }
88
+
89
+ /**
90
+ * Handles the print workflow: replaces field variables in markdown, converts to HTML,
91
+ * sanitizes, and opens a print dialog.
92
+ *
93
+ * @param records - The data records (selected features) to print, one page per record.
94
+ * @param markdown - The markdown template string with ${fieldName} or ${fieldName|format} placeholders.
95
+ * @param css - The custom CSS to apply to the print output.
96
+ * @returns "Success" if the print dialog was opened, or an error message string.
97
+ */
98
+ export const handlePrint = (
99
+ records: any[],
100
+ markdown: string,
101
+ css: string
102
+ ): string => {
103
+ if (!records || records.length === 0) {
104
+ return "No features selected. Please select at least one feature before printing."
105
+ }
106
+ if (!markdown) {
107
+ return "No markdown content configured for this template."
108
+ }
109
+
110
+ try {
111
+ const pages: string[] = []
112
+ for (const feature of records) {
113
+ const result = markdown.replace(/\${(.*?)}/g, (_match, contents) => {
114
+ const [fieldName, ...formatParts] = contents.split("|")
115
+ const field = fieldName.trim()
116
+ const format = formatParts.length
117
+ ? formatParts.join("|").trim().replace(/['"]/g, "")
118
+ : null
119
+ const out = applyFormat(feature.getData()[field], format) ?? ""
120
+ return out
121
+ })
122
+ const htmlOut = `<div class="markdown-content">${DOMPurify.sanitize(marked.parse(result, { async: false }))}</div>`
123
+ const formattedHtml = htmlOut.replace(/\n/g, "<br>")
124
+ pages.push(formattedHtml)
125
+ }
126
+ const combinedHtml = pages.join(
127
+ '<div style="page-break-after: always;"></div>'
128
+ )
129
+ const cleanCss = "@page { margin: 20px; } " + css.replace(/\n/g, "")
130
+ printJS({ printable: combinedHtml, type: "raw-html", style: cleanCss })
131
+ return "Success"
132
+ } catch (error) {
133
+ return `Print failed: ${error instanceof Error ? error.message : String(error)}`
134
+ }
135
+ }
@@ -0,0 +1,82 @@
1
+ .widget-mdprint {
2
+ display: flex;
3
+ flex-direction: column;
4
+ height: 100%;
5
+ min-height: 0;
6
+ }
7
+
8
+ /* Scrollable area that fills remaining space above the button */
9
+ .widget-mdprint .mdprint-scroll {
10
+ overflow: auto;
11
+ flex: 1 1 auto;
12
+ min-height: 0;
13
+ padding-right: 16px;
14
+ padding-left: 16px;
15
+ }
16
+
17
+ /* Make the calcite print button full width and stick to the bottom of the widget */
18
+ .widget-mdprint calcite-button {
19
+ width: 100%;
20
+ flex: 0 0 auto;
21
+ box-sizing: border-box;
22
+ position: sticky;
23
+ bottom: 0;
24
+ z-index: 3;
25
+ padding-right: 16px;
26
+ padding-left: 16px;
27
+ padding-bottom: 16px;
28
+ }
29
+
30
+ /* Add a little bottom padding so content isn't flush against the button */
31
+ .widget-mdprint .mdprint-scroll > *:last-child {
32
+ padding-bottom: 12px;
33
+ }
34
+
35
+ .widget-mdprint header {
36
+ border-bottom: 1px solid rgba(0,0,0,0.12);
37
+ }
38
+
39
+ .widget-mdprint h4 {
40
+ font-size: 16px;
41
+ font-weight: 500;
42
+ margin: 0;
43
+ padding-bottom: 10px;
44
+ padding-top: 10px;
45
+ padding-left: 12px;
46
+ padding-right: 12px;
47
+ }
48
+
49
+ .widget-mdprint .mdprint-scroll {
50
+ padding-top: 8px;
51
+ }
52
+
53
+ .mdprint-field-header {
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: space-between;
57
+ font-weight: 400;
58
+ margin: 8px 0 0 0;
59
+ }
60
+
61
+ .mdprint-field-header .label {
62
+ margin: 0;
63
+ font-weight: 400;
64
+ }
65
+
66
+ .mdprint-field-header calcite-button,
67
+ .mdprint-field-header .expand-button {
68
+ flex: 0 0 auto;
69
+ margin-left: 8px;
70
+ }
71
+
72
+ .widget-mdprint .text-truncate {
73
+ font-weight: 400
74
+ }
75
+
76
+ .widget-mdprint textarea {
77
+ border-radius: 4px;
78
+ }
79
+
80
+ .modal-content textarea {
81
+ border-radius: 4px;
82
+ }
@@ -0,0 +1,396 @@
1
+ import {
2
+ type DataSource,
3
+ DataSourceComponent,
4
+ type FeatureLayerQueryParams,
5
+ React,
6
+ type AllWidgetProps,
7
+ type DataRecord,
8
+ type ImmutableObject,
9
+ Immutable
10
+ } from "jimu-core"
11
+ import type { IMConfig, PrintTemplate } from "../config"
12
+ import {
13
+ Button,
14
+ CollapsablePanel,
15
+ Icon,
16
+ Modal,
17
+ ModalBody,
18
+ ModalHeader,
19
+ Paper,
20
+ TextArea
21
+ } from "jimu-ui"
22
+ import {
23
+ CalciteAlert,
24
+ CalciteButton,
25
+ CalciteOption,
26
+ CalciteSelect
27
+ } from "calcite-components"
28
+ import { handlePrint } from "./formatUtils"
29
+ import "./style.css"
30
+
31
+ export default function Widget(props: AllWidgetProps<IMConfig>) {
32
+ const { config } = props
33
+ // Store datasources keyed by datasource ID to avoid duplicates
34
+ const [datasources, setDatasources] = React.useState<{
35
+ [dsId: string]: DataSource
36
+ }>({})
37
+ // Store selected records keyed by datasource ID
38
+ const [selectedByDsId, setSelectedByDsId] = React.useState<{
39
+ [dsId: string]: DataRecord[]
40
+ }>({})
41
+ const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>(
42
+ config?.PrintTemplates?.[0]?.id || ""
43
+ )
44
+
45
+ // Runtime overrides for markdown and css, keyed by template ID
46
+ const [markdownOverrides, setMarkdownOverrides] = React.useState<{
47
+ [templateId: string]: string
48
+ }>({})
49
+ const [cssOverrides, setCssOverrides] = React.useState<{
50
+ [templateId: string]: string
51
+ }>({})
52
+
53
+ // Alert state
54
+ const [alertOpen, setAlertOpen] = React.useState(false)
55
+ const [alertMessage, setAlertMessage] = React.useState("")
56
+
57
+ // Modal state for markdown editor
58
+ const [mdModalOpen, setMdModalOpen] = React.useState(false)
59
+ const openMDModal = () => {
60
+ setMdModalOpen(true)
61
+ }
62
+ const closeMDModal = () => {
63
+ setMdModalOpen(false)
64
+ }
65
+
66
+ // Modal state for css editor
67
+ const [cssModalOpen, setCssModalOpen] = React.useState(false)
68
+ const openCSSModal = () => {
69
+ setCssModalOpen(true)
70
+ }
71
+ const closeCSSModal = () => {
72
+ setCssModalOpen(false)
73
+ }
74
+
75
+ const showAlert = (message: string) => {
76
+ setAlertMessage(message)
77
+ setAlertOpen(true)
78
+ }
79
+
80
+ const isConfigured =
81
+ config?.PrintTemplates &&
82
+ config.PrintTemplates.length > 0 &&
83
+ config.PrintTemplates.some(
84
+ (t) => t.useDataSources && t.useDataSources.length > 0
85
+ )
86
+
87
+ // Collect unique useDataSources across all templates (deduplicated by dataSourceId)
88
+ const uniqueUseDataSources = React.useMemo(() => {
89
+ if (!config?.PrintTemplates) return []
90
+ const seen = new Set<string>()
91
+ const result = []
92
+ for (const template of config.PrintTemplates) {
93
+ if (template.useDataSources && template.useDataSources.length > 0) {
94
+ const uds = template.useDataSources[0]
95
+ if (!seen.has(uds.dataSourceId)) {
96
+ seen.add(uds.dataSourceId)
97
+ result.push(uds)
98
+ }
99
+ }
100
+ }
101
+ return result
102
+ }, [config?.PrintTemplates])
103
+
104
+ // Get the datasource ID for a given template
105
+ const getDsIdForTemplate = (
106
+ template: ImmutableObject<PrintTemplate>
107
+ ): string | null => {
108
+ return template.useDataSources?.[0]?.dataSourceId ?? null
109
+ }
110
+
111
+ // Get selected records for a given template's datasource
112
+ const getSelectedRecordsForTemplate = (
113
+ template: ImmutableObject<PrintTemplate>
114
+ ): DataRecord[] => {
115
+ const dsId = getDsIdForTemplate(template)
116
+ if (!dsId) return []
117
+ return selectedByDsId[dsId] || []
118
+ }
119
+
120
+ const doPrint = (template: ImmutableObject<PrintTemplate>) => {
121
+ const dsId = getDsIdForTemplate(template)
122
+ if (!dsId || !datasources[dsId]) {
123
+ showAlert("No data source available for this template.")
124
+ return
125
+ }
126
+ const records = selectedByDsId[dsId] || []
127
+
128
+ // Use runtime overrides if present, otherwise fall back to config values
129
+ const markdown =
130
+ markdownOverrides[template.id] !== undefined
131
+ ? markdownOverrides[template.id]
132
+ : template.markdown
133
+ const css =
134
+ cssOverrides[template.id] !== undefined
135
+ ? cssOverrides[template.id]
136
+ : template.css
137
+
138
+ const result = handlePrint(records, markdown, css)
139
+ if (result !== "Success") {
140
+ showAlert(result)
141
+ }
142
+ }
143
+
144
+ // Count of selected features for the currently active template
145
+ const selectedCount = React.useMemo(() => {
146
+ const template = config?.PrintTemplates?.find(
147
+ (t) => t.id === selectedTemplateId
148
+ )
149
+ if (!template) return 0
150
+ const dsId = template.useDataSources?.[0]?.dataSourceId
151
+ if (!dsId) return 0
152
+ return (selectedByDsId[dsId] || []).length
153
+ }, [config?.PrintTemplates, selectedTemplateId, selectedByDsId])
154
+
155
+ if (!isConfigured) {
156
+ return (
157
+ <Paper
158
+ variant="flat"
159
+ shape="none"
160
+ className="widget-mdprint jimu-widget"
161
+ style={{ whiteSpace: "pre-wrap", padding: "16px" }}
162
+ >
163
+ <p>Please configure print templates in the widget settings.</p>
164
+ </Paper>
165
+ )
166
+ }
167
+
168
+ return (
169
+ <Paper
170
+ variant="flat"
171
+ shape="none"
172
+ className="widget-mdprint jimu-widget"
173
+ style={{ whiteSpace: "pre-wrap" }}
174
+ >
175
+ <header className="widget-header">
176
+ <h4>Markdown Printer</h4>
177
+ </header>
178
+ <div className="mdprint-scroll">
179
+ <span style={{ fontSize: "14px" }}>Template</span>
180
+ <CalciteSelect
181
+ label="Select Print Template"
182
+ scale="s"
183
+ value={selectedTemplateId}
184
+ onCalciteSelectChange={(e: any) => {
185
+ const templateId = (e.target as HTMLCalciteSelectElement).value
186
+ setSelectedTemplateId(templateId)
187
+ }}
188
+ >
189
+ {config.PrintTemplates.map(
190
+ (template: ImmutableObject<PrintTemplate>) => {
191
+ const records = getSelectedRecordsForTemplate(template)
192
+ return (
193
+ <CalciteOption key={template.id} value={template.id}>
194
+ {template.label +
195
+ (records.length > 0 ? ` (${records.length} selected)` : "")}
196
+ </CalciteOption>
197
+ )
198
+ }
199
+ )}
200
+ </CalciteSelect>
201
+ {/* Show markdown and CSS editors for the currently selected template */}
202
+ {selectedTemplateId &&
203
+ config.PrintTemplates.map(
204
+ (template: ImmutableObject<PrintTemplate>) => {
205
+ if (template.id !== selectedTemplateId) return null
206
+ const currentMarkdown =
207
+ markdownOverrides[template.id] !== undefined
208
+ ? markdownOverrides[template.id]
209
+ : template.markdown
210
+ const currentCss =
211
+ cssOverrides[template.id] !== undefined
212
+ ? cssOverrides[template.id]
213
+ : template.css
214
+ return (
215
+ <CollapsablePanel
216
+ key={template.id}
217
+ label="Edit Template"
218
+ defaultIsOpen={false}
219
+ level={3}
220
+ >
221
+ <div className="mdprint-field-header">
222
+ <div className="label">Markdown Content</div>
223
+ <Button
224
+ icon={true}
225
+ className="expand-button"
226
+ size="sm"
227
+ variant="text"
228
+ onClick={openMDModal}
229
+ >
230
+ <Icon
231
+ size="m"
232
+ icon='<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M15 15h-4v-1h2.282l-3.633-3.584.767-.767L14 13.282V11h1zM5 1v1H2.718l3.633 3.584-.767.767L2 2.718V5H1V1z"></path></svg>'
233
+ ></Icon>
234
+ </Button>
235
+ <Modal
236
+ centered
237
+ keyboard
238
+ scrollable
239
+ toggle={closeMDModal}
240
+ isOpen={mdModalOpen}
241
+ onRequestClose={closeMDModal}
242
+ >
243
+ <ModalHeader toggle={closeMDModal}>
244
+ Markdown Content
245
+ </ModalHeader>
246
+ <ModalBody>
247
+ <TextArea
248
+ height={window.innerHeight * 0.8}
249
+ value={currentMarkdown}
250
+ onChange={(e) => {
251
+ const val = e.currentTarget.value
252
+ setMarkdownOverrides((prev) => ({
253
+ ...prev,
254
+ [template.id]: val
255
+ }))
256
+ }}
257
+ placeholder="Enter markdown content here"
258
+ />
259
+ </ModalBody>
260
+ </Modal>
261
+ </div>
262
+ <TextArea
263
+ value={currentMarkdown}
264
+ height={100}
265
+ onChange={(e) => {
266
+ const val = e.currentTarget.value
267
+ setMarkdownOverrides((prev) => ({
268
+ ...prev,
269
+ [template.id]: val
270
+ }))
271
+ }}
272
+ placeholder="Enter markdown content here"
273
+ />
274
+ <div className="mdprint-field-header">
275
+ <div className="label">Custom CSS</div>
276
+ <Button
277
+ icon={true}
278
+ className="expand-button"
279
+ size="sm"
280
+ variant="text"
281
+ onClick={openCSSModal}
282
+ >
283
+ <Icon
284
+ size="m"
285
+ icon='<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16"><path d="M15 15h-4v-1h2.282l-3.633-3.584.767-.767L14 13.282V11h1zM5 1v1H2.718l3.633 3.584-.767.767L2 2.718V5H1V1z"></path></svg>'
286
+ ></Icon>
287
+ </Button>
288
+ <Modal
289
+ centered
290
+ keyboard
291
+ scrollable
292
+ toggle={closeCSSModal}
293
+ isOpen={cssModalOpen}
294
+ onRequestClose={closeCSSModal}
295
+ >
296
+ <ModalHeader toggle={closeCSSModal}>
297
+ CSS Content
298
+ </ModalHeader>
299
+ <ModalBody>
300
+ <TextArea
301
+ height={window.innerHeight * 0.8}
302
+ value={currentCss}
303
+ onChange={(e) => {
304
+ const val = e.currentTarget.value
305
+ setCssOverrides((prev) => ({
306
+ ...prev,
307
+ [template.id]: val
308
+ }))
309
+ }}
310
+ placeholder="Enter CSS content here"
311
+ />
312
+ </ModalBody>
313
+ </Modal>
314
+ </div>
315
+ <TextArea
316
+ value={currentCss}
317
+ height={100}
318
+ onChange={(e) => {
319
+ const val = e.currentTarget.value
320
+ setCssOverrides((prev) => ({
321
+ ...prev,
322
+ [template.id]: val
323
+ }))
324
+ }}
325
+ placeholder="Enter custom CSS here"
326
+ />
327
+ </CollapsablePanel>
328
+ )
329
+ }
330
+ )}
331
+ </div>
332
+
333
+ <CalciteButton
334
+ appearance="solid"
335
+ disabled={!selectedTemplateId || selectedCount === 0}
336
+ style={{ marginTop: "8px" }}
337
+ className="print-button"
338
+ onClick={() => {
339
+ const template = config.PrintTemplates.find(
340
+ (t) => t.id === selectedTemplateId
341
+ )
342
+ if (template) {
343
+ doPrint(Immutable(template))
344
+ }
345
+ }}
346
+ >
347
+ Print {selectedCount > 0 ? `(${selectedCount} selected)` : ""}
348
+ </CalciteButton>
349
+ {/* Render a DataSourceComponent for each unique datasource */}
350
+ {uniqueUseDataSources.map((uds) => (
351
+ <DataSourceComponent
352
+ key={uds.dataSourceId}
353
+ useDataSource={uds}
354
+ query={
355
+ {
356
+ where: "1=1",
357
+ outFields: ["*"],
358
+ returnGeometry: true
359
+ } as FeatureLayerQueryParams
360
+ }
361
+ widgetId={props.id}
362
+ onDataSourceCreated={(ds: DataSource) => {
363
+ setDatasources((prev) => {
364
+ if (prev[ds.id]) return prev
365
+ return { ...prev, [ds.id]: ds }
366
+ })
367
+ }}
368
+ onSelectionChange={() => {
369
+ // Look up the ds from our stored datasources by the expected ID
370
+ const ds = datasources[uds.dataSourceId]
371
+ if (!ds) return
372
+ const records = ds.getSelectedRecords()
373
+ setSelectedByDsId((prev) => ({
374
+ ...prev,
375
+ [ds.id]: records
376
+ }))
377
+ }}
378
+ />
379
+ ))}
380
+ <CalciteAlert
381
+ open={alertOpen || undefined}
382
+ kind="danger"
383
+ icon="exclamation-mark-triangle"
384
+ autoClose
385
+ autoCloseDuration="medium"
386
+ label="Error alert"
387
+ onCalciteAlertClose={() => {
388
+ setAlertOpen(false)
389
+ }}
390
+ >
391
+ <div slot="title">Error</div>
392
+ <div slot="message">{alertMessage}</div>
393
+ </CalciteAlert>
394
+ </Paper>
395
+ )
396
+ }
@@ -0,0 +1,165 @@
1
+ import {
2
+ React,
3
+ Immutable,
4
+ type UseDataSource,
5
+ DataSourceTypes,
6
+ utils,
7
+ type ImmutableObject
8
+ } from "jimu-core"
9
+ import type { AllWidgetSettingProps } from "jimu-for-builder"
10
+ import { Button, TextInput, Tabs, Tab, TextArea } from "jimu-ui"
11
+ import { SettingRow, SettingSection } from "jimu-ui/advanced/setting-components"
12
+ import { DataSourceSelector } from "jimu-ui/advanced/data-source-selector"
13
+ import type { IMConfig, PrintTemplate } from "../config"
14
+
15
+ export default function Setting(props: AllWidgetSettingProps<IMConfig>) {
16
+ const { id, config } = props
17
+ const [activeTab, setActiveTab] = React.useState<string | undefined>(
18
+ config?.PrintTemplates?.[0]?.id
19
+ )
20
+
21
+ // helper to get a mutable copy of templates
22
+ const getTemplates = () =>
23
+ config?.PrintTemplates
24
+ ? config.PrintTemplates.asMutable({ deep: true })
25
+ : []
26
+
27
+ const updateTemplate = (templateId: string, newData: Partial<any>) => {
28
+ const arr = getTemplates()
29
+ const index = arr.findIndex((template: any) => template.id === templateId)
30
+ if (index === -1) return
31
+ arr[index] = { ...arr[index], ...newData }
32
+ const newConfig = (config || Immutable({})).set("PrintTemplates", arr)
33
+ props.onSettingChange({ id, config: newConfig })
34
+ }
35
+
36
+ const addPrintTemplate = () => {
37
+ const newTemplate = {
38
+ id: `${id}-template-${utils.getUUID()}`,
39
+ label: `Template ${config?.PrintTemplates?.length + 1 || 1}`,
40
+ markdown:
41
+ "# Markdown Title\n\nThis is an example of markdown content in the MDPrint widget. You can use **bold**, *italic*, and other markdown syntax to format your content.\n\n- Item 1\n- Item 2\n- Item 3\n\n[ArcGIS Experience Builder](https://experience.arcgis.com/)",
42
+ css: ".markdown-content {\n font-family: 'Noto Sans', Arial, Helvetica, sans-serif;\n color: #333;\n}\n\n.markdown-content h1 {\n color: #0078d4;\n}\n\n.markdown-content a {\n color: #0078d4;\n text-decoration: none;\n}\n\n.markdown-content a:hover {\n text-decoration: underline;\n}"
43
+ }
44
+ const arr = getTemplates()
45
+ arr.push(newTemplate)
46
+ const newConfig = (config || Immutable({})).set("PrintTemplates", arr)
47
+ props.onSettingChange({ id, config: newConfig })
48
+ }
49
+
50
+ const removePrintTemplate = (templateId: string) => {
51
+ const arr = getTemplates().filter((t: any) => t.id !== templateId)
52
+ const newConfig = (config || Immutable({})).set("PrintTemplates", arr)
53
+ props.onSettingChange({ id, config: newConfig })
54
+ }
55
+ /*
56
+ const onDataSourceChange = (useDataSources: UseDataSource[]) => {
57
+ props.onSettingChange({
58
+ id: props.id,
59
+ useDataSources: useDataSources
60
+ })
61
+ }
62
+ */
63
+ return (
64
+ <div>
65
+ <SettingSection title="Markdown Print Setup">
66
+ <SettingRow level={1} flow="wrap">
67
+ <Button type="secondary" onClick={addPrintTemplate}>
68
+ Add Print Template
69
+ </Button>
70
+ </SettingRow>
71
+ <SettingRow level={1} flow="wrap">
72
+ {config?.PrintTemplates && config.PrintTemplates.length > 0 && (
73
+ <Tabs
74
+ value={activeTab}
75
+ onChange={(id) => {
76
+ setActiveTab(id)
77
+ }}
78
+ type="tabs"
79
+ scrollable={true}
80
+ keepMount
81
+ style={{ margin: "0 8px" }}
82
+ onClose={(id) => {
83
+ removePrintTemplate(id)
84
+ }}
85
+ children={
86
+ config.PrintTemplates.map(
87
+ (template: ImmutableObject<PrintTemplate>) => (
88
+ <Tab
89
+ id={template.id}
90
+ key={template.id}
91
+ title={template.label}
92
+ closeable
93
+ >
94
+ <SettingRow label="Template Name" level={1} flow="wrap">
95
+ <TextInput
96
+ value={template.label}
97
+ onChange={(e) => {
98
+ updateTemplate(template.id, {
99
+ label: e.currentTarget.value
100
+ })
101
+ }}
102
+ />
103
+ </SettingRow>
104
+ <SettingRow
105
+ label="Select Data Source"
106
+ level={1}
107
+ flow="wrap"
108
+ >
109
+ <DataSourceSelector
110
+ types={Immutable([DataSourceTypes.FeatureLayer])}
111
+ mustUseDataSource={true}
112
+ isMultiple={false}
113
+ useDataSources={template.useDataSources}
114
+ useDataSourcesEnabled={props.useDataSourcesEnabled}
115
+ onChange={(uds: UseDataSource[]) => {
116
+ updateTemplate(template.id, { useDataSources: uds })
117
+ }}
118
+ widgetId={props.id}
119
+ />
120
+ </SettingRow>
121
+ <SettingRow
122
+ label="Markdown Content"
123
+ level={2}
124
+ flow="wrap"
125
+ >
126
+ <TextArea
127
+ value={template.markdown}
128
+ height={window.innerHeight * 0.4}
129
+ onChange={(e) => {
130
+ updateTemplate(template.id, {
131
+ markdown: e.currentTarget.value
132
+ })
133
+ }}
134
+ placeholder="Enter markdown content here"
135
+ />
136
+ </SettingRow>
137
+ <SettingRow label="Custom CSS" level={2} flow="wrap">
138
+ <TextArea
139
+ value={template.css}
140
+ height={window.innerHeight * 0.4}
141
+ onChange={(e) => {
142
+ updateTemplate(template.id, {
143
+ css: e.currentTarget.value
144
+ })
145
+ }}
146
+ placeholder="Enter custom CSS here"
147
+ />
148
+ </SettingRow>
149
+ </Tab>
150
+ )
151
+ ) as any
152
+ }
153
+ />
154
+ )}
155
+ </SettingRow>
156
+ {!config?.PrintTemplates || config.PrintTemplates.length === 0 ? (
157
+ <div>
158
+ No print templates configured. Click "Add Print Template" to create
159
+ one.
160
+ </div>
161
+ ) : null}
162
+ </SettingSection>
163
+ </div>
164
+ )
165
+ }