exb-calendar 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,28 @@
1
+ # Calendar Widget
2
+
3
+ This widget provides a simple interactive calendar to work alongside an ArcGIS Online feature layer. Using the widget is as simple as adding the widget to your application, selecting a data source, and then mapping the fields. This widget is also set up to use message actions, meaning selecting an event on the calendar also selects the feature on any maps, lists, tables, etc. that the data source is also used in. This can also be set to zoom in on a map, apply filtering, and much more. Data filtering also works on the calendar, so if the data source is filtered by another widget, the calendar events will also be filtered.
4
+
5
+ ## Interactive Example
6
+
7
+ This widget can be used and interacted with on my example application, found [here](https://exb.luciuscreamer.com/calendar). Worth noting, the calendar widget follows Experience Builder styling, to fit the look of your application. If you don't vibe with the dark theme, the widget works just fine in a light themed application.
8
+ ![A screenshot of the calendar application](./calendarExample.png)
9
+
10
+ ## Setting up the Calendar
11
+
12
+ The calendar has a few data formatting requirements if you want all features to work flawlessly OOTB. These will be explained below.
13
+
14
+ 1. Label Field
15
+ - The label field is what shows up on the event in the calendar (This is a short tagline.)
16
+ - Type: Text
17
+ 2. Start Date
18
+ - The start date needs to be a Date field, and is required for all events to be populated on the calendar.
19
+ - Type: Date (Date and time)
20
+ 3. End Date
21
+ - The end date needs to be a date field, but is not required for "all day" events.
22
+ - Type: Date (Date and time)
23
+ 4. All Day Field
24
+ - The all day field is a simple "y/n" field, to denote whether an event is an "all day" event. The widget only checks if the field is set to "y" to trigger it treating an event as an "all day" event.
25
+ - Type: Text
26
+ 5. Description Field
27
+ - The description is shown in the tooltip shown when the user hovers over the event. This can be a longer text field, but should likely not be longer than a sentence or two.
28
+ - Type: text
Binary file
package/config.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "icon": "Calendar"
3
+ }
package/icon.svg ADDED
@@ -0,0 +1,7 @@
1
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
2
+
3
+ <svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="#ffffff">
4
+
5
+
6
+
7
+
package/manifest.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "Calendar",
3
+ "label": "Calendar",
4
+ "type": "widget",
5
+ "version": "1.19.0",
6
+ "exbVersion": "1.19.0",
7
+ "author": "Lucius Creamer",
8
+ "description": "This widget uses a calendar from the fullcalendar library, to display ArcGIS Data in interactable calendar format.",
9
+ "copyright": "",
10
+ "dependency": [
11
+ "jimu-arcgis"
12
+ ],
13
+ "license": "http://www.apache.org/licenses/LICENSE-2.0",
14
+ "publishMessages": ["DATA_RECORDS_SELECTION_CHANGE"],
15
+ "properties": {},
16
+ "translatedLocales": [
17
+ "en"
18
+ ],
19
+ "defaultSize": {
20
+ "width": 800,
21
+ "height": 500
22
+ }
23
+ }
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "exb-calendar",
3
+ "version": "1.0.0",
4
+ "description": "A Calendar widget for Experience Builder Developer Edition",
5
+ "license": "MIT",
6
+ "author": "Lucius Creamer",
7
+ "dependencies": {
8
+ "@fullcalendar/daygrid": "^6.1.20",
9
+ "@fullcalendar/interaction": "^6.1.20",
10
+ "@fullcalendar/react": "^6.1.20",
11
+ "@fullcalendar/timegrid": "^6.1.20"
12
+ },
13
+ "peerDependencies":{
14
+ "polished": "4.3.1"
15
+ }
16
+ }
package/src/config.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { ImmutableObject } from 'seamless-immutable'
2
+ import type { UseDataSource } from 'jimu-core'
3
+
4
+ export interface colorset {
5
+ id: string;
6
+ fieldValue: string;
7
+ color: string;
8
+ }
9
+
10
+ export interface data {
11
+ id: string // Unique ID for React keys
12
+
13
+ // Individual field keys selected in the settings. Store as a single field name (jimuName).
14
+ labelField?: string
15
+ startDateField?: string
16
+ endDateField?: string
17
+ allDayField?: string
18
+ descriptionField?: string
19
+ colorsetField?: string
20
+
21
+ // Color settings
22
+ defaultEventColor?: string
23
+ colorsets?: colorset[]
24
+ // optional datasource selection saved per dataset
25
+ useDataSources?: UseDataSource[]
26
+ }
27
+
28
+ export interface Config {
29
+ dataSets: data[]
30
+ }
31
+
32
+ export type IMConfig = ImmutableObject<Config>
@@ -0,0 +1,51 @@
1
+ :root{
2
+ --fc-small-font-size: .85em;
3
+ --fc-page-bg-color: var(--sys-color-surface-background);
4
+ --fc-neutral-bg-color: hsla(0, 0%, 82%, .3);
5
+ --fc-neutral-text-color: var(--sys-color-surface-header-text);
6
+ --fc-border-color: var(--sys-color-divider-primary);
7
+ --fc-button-text-color: var(--sys-color-action-text);
8
+ --fc-button-bg-color: var(--sys-color-action);
9
+ --fc-button-border-color: var(--sys-color-divider-switch);
10
+ --fc-button-hover-bg-color: var(--sys-color-action-hover);
11
+ --fc-button-hover-border-color: var(--sys-color-divider-switch);
12
+ --fc-button-active-bg-color: var(--sys-color-action-selected);
13
+ --fc-button-active-border-color: var(--sys-color-divider-switch);
14
+ --fc-event-bg-color: var(--sys-color-secondary-light);
15
+ --fc-event-border-color: var(--sys-color-divider-switch);
16
+ --fc-event-text-color: var(--sys-color-action-selected-text);
17
+ --fc-event-selected-overlay-color: rgba(0, 0, 0, 0.3);
18
+ --fc-more-link-bg-color: #d0d0d0;
19
+ --fc-more-link-text-color: inherit;
20
+ --fc-event-resizer-thickness: 8px;
21
+ --fc-event-resizer-dot-total-width: 8px;
22
+ --fc-event-resizer-dot-border-width: 1px;
23
+ --fc-non-business-color: hsla(0, 0%, 84%, .3);
24
+ --fc-bg-event-color: #8fdf82;
25
+ --fc-bg-event-opacity: 0.3;
26
+ --fc-highlight-color: var(--sys-color-action-focus);
27
+ --fc-today-bg-color: rgba(255, 220, 40, .15);
28
+ --fc-now-indicator-color: red;
29
+ }
30
+
31
+
32
+ .fc-button-primary:not(:disabled).fc-button-active{
33
+ color: var(--sys-color-action-selected-text)
34
+ }
35
+
36
+ .fc {
37
+ box-sizing: content-box;
38
+ height: 100%;
39
+ width:100%;
40
+ }
41
+
42
+ .widget-calendar-not-configured {
43
+ display: flex;
44
+ flex-direction: column;
45
+ align-items: center;
46
+ justify-content: center;
47
+ height: 100%;
48
+ width: 100%;
49
+ background-color: var(--light);
50
+ color: var(--dark);
51
+ }
@@ -0,0 +1,4 @@
1
+ export default {
2
+ _widgetLabel: 'Calendar',
3
+ notConfigured: 'Please configure the widget in the settings pane.'
4
+ }
@@ -0,0 +1,229 @@
1
+ import {
2
+ React,
3
+ type AllWidgetProps,
4
+ type DataSource,
5
+ DataSourceComponent,
6
+ type FeatureLayerQueryParams,
7
+ DataSourceStatus,
8
+ MessageManager,
9
+ DataRecordsSelectionChangeMessage
10
+ } from "jimu-core"
11
+ import type { data, IMConfig } from "../config"
12
+ import "./style.css"
13
+
14
+ import FullCalendar from "@fullcalendar/react"
15
+ import dayGridPlugin from "@fullcalendar/daygrid"
16
+ import interactionPlugin from "@fullcalendar/interaction"
17
+ import timeGridPlugin from "@fullcalendar/timegrid"
18
+ import { cssVar } from "polished"
19
+
20
+ export default function Widget(props: AllWidgetProps<IMConfig>) {
21
+ const { config } = props
22
+ const [datasources, setDatasources] = React.useState<DataSource[]>([])
23
+ // Store events keyed by datasource ID to support multiple datasources
24
+ const [eventsByDsId, setEventsByDsId] = React.useState<{
25
+ [dsId: string]: any[]
26
+ }>({})
27
+
28
+ // Compute flat events array from all datasources
29
+ const events = Object.values(eventsByDsId).flat()
30
+
31
+ const isConfigured =
32
+ config.dataSets &&
33
+ config.dataSets.length > 0 &&
34
+ config.dataSets[0].useDataSources &&
35
+ config.dataSets[0].useDataSources.length === 1 &&
36
+ config.dataSets[0].startDateField &&
37
+ config.dataSets[0].endDateField
38
+
39
+ const fillCalendarEvents = (ds: DataSource, dsConfig: data) => {
40
+ if (!ds) return
41
+ try {
42
+ const dsId = ds.id
43
+ const records = ds.getRecords() || []
44
+ const currentEventsForDs = eventsByDsId[dsId] || []
45
+
46
+ // Only update if record count changed to avoid unnecessary state updates
47
+ if (records.length === currentEventsForDs.length) return
48
+
49
+ const loadedEvents = records.map((record) => {
50
+ const title = record.getFieldValue(dsConfig.labelField) as string
51
+ const rawStart = record.getFieldValue(dsConfig.startDateField)
52
+ const rawEnd = record.getFieldValue(dsConfig.endDateField)
53
+ const rawAllDay = record.getFieldValue(dsConfig.allDayField) as string
54
+ const description = record.getFieldValue(
55
+ dsConfig.descriptionField
56
+ ) as string
57
+ const colorFieldValue = record.getFieldValue(
58
+ dsConfig.colorsetField
59
+ ) as string
60
+
61
+ let start: string, end: string, color: string | number
62
+
63
+ const toISO = (v: any) => {
64
+ if (v == null) return undefined
65
+ const d = v instanceof Date ? v : new Date(v)
66
+ return isNaN(d.getTime()) ? undefined : d.toISOString()
67
+ }
68
+ const toISODateOnly = (v: any) => {
69
+ if (v == null) return undefined
70
+ const d = v instanceof Date ? v : new Date(v)
71
+ d.setHours(d.getHours() - d.getTimezoneOffset() / 60) // Adjust to local date
72
+ return isNaN(d.getTime()) ? undefined : d.toISOString().split("T")[0]
73
+ }
74
+
75
+ if (rawAllDay === "y") {
76
+ start = toISODateOnly(rawStart)
77
+ } else {
78
+ start = toISO(rawStart)
79
+ end = toISO(rawEnd)
80
+ }
81
+
82
+ if (dsConfig.colorsets && colorFieldValue) {
83
+ const matchedColorSet = dsConfig.colorsets.find(
84
+ (cs) => cs.fieldValue === colorFieldValue
85
+ )
86
+ if (matchedColorSet) {
87
+ color = matchedColorSet.color
88
+ } else {
89
+ color =
90
+ dsConfig.defaultEventColor ||
91
+ cssVar("--ref-palette-secondary-500")
92
+ }
93
+ } else {
94
+ color =
95
+ dsConfig.defaultEventColor || cssVar("--ref-palette-secondary-500")
96
+ }
97
+
98
+ return {
99
+ id: `${dsId}_${record.getId()}`, // Prefix with dsId to ensure unique IDs across datasources
100
+ originalId: record.getId(),
101
+ dataSource: ds,
102
+ title: title ?? "",
103
+ start: start,
104
+ end: end ?? undefined,
105
+ color: color,
106
+ description: description ?? ""
107
+ }
108
+ })
109
+
110
+ // Update only this datasource's events, preserving others
111
+ setEventsByDsId((prev) => ({
112
+ ...prev,
113
+ [dsId]: loadedEvents
114
+ }))
115
+ } catch (e) {
116
+ console.error("Failed to load events from datasource", e)
117
+ }
118
+ }
119
+
120
+ const handleEventClick = (clickInfo) => {
121
+ const eventDs = clickInfo.event.extendedProps.dataSource as DataSource
122
+ const originalId = clickInfo.event.extendedProps.originalId as string
123
+ eventDs.selectRecordsByIds([originalId])
124
+ const record = eventDs.getRecordById(originalId)
125
+ const message = new DataRecordsSelectionChangeMessage(
126
+ props.widgetId,
127
+ [record],
128
+ [eventDs.id]
129
+ )
130
+ MessageManager.getInstance().publishMessage(message)
131
+ }
132
+
133
+ const handleClearSelection = () => {
134
+ /// Loop through all datasets, if there is a selection, clear it and post a message.
135
+ datasources.forEach((ds) => {
136
+ if (ds.getSelectedRecords().length > 0) {
137
+ ds.clearSelection()
138
+ const message = new DataRecordsSelectionChangeMessage(
139
+ props.widgetId,
140
+ [],
141
+ [props.useDataSources[0].dataSourceId]
142
+ )
143
+ MessageManager.getInstance().publishMessage(message)
144
+ }
145
+ })
146
+ }
147
+
148
+ if (!isConfigured) {
149
+ return (
150
+ <div className="widget-calendar-not-configured">
151
+ Please configure the Calendar widget in the settings panel.
152
+ </div>
153
+ )
154
+ }
155
+
156
+ return (
157
+ <>
158
+ <FullCalendar
159
+ plugins={[timeGridPlugin, dayGridPlugin, interactionPlugin]}
160
+ initialView="dayGridMonth"
161
+ events={events}
162
+ eventClick={handleEventClick}
163
+ eventDidMount={(info) => {
164
+ // Tooltip showing full date/time info
165
+ const description = info.event.extendedProps.description
166
+ const start = info.event.start
167
+ const end = info.event.end
168
+ let tooltipText = description + "\nStart: " + start.toLocaleString()
169
+ if (end) {
170
+ tooltipText += "\nEnd: " + end.toLocaleString()
171
+ }
172
+ info.el.setAttribute("title", tooltipText)
173
+ }}
174
+ customButtons={{
175
+ clearSelection: {
176
+ text: "Clear Selection",
177
+ click: () => {
178
+ handleClearSelection()
179
+ }
180
+ }
181
+ }}
182
+ buttonText={{
183
+ today: "Today",
184
+ month: "Month",
185
+ week: "Week",
186
+ day: "Day"
187
+ }}
188
+ headerToolbar={{
189
+ left: "prev,next today clearSelection",
190
+ center: "title",
191
+ right: "dayGridMonth,timeGridWeek,timeGridDay"
192
+ }}
193
+ />
194
+ {config.dataSets && config.dataSets.length > 0 && (
195
+ <>
196
+ {config.dataSets.map((dsConfig, index) => (
197
+ <DataSourceComponent
198
+ key={index}
199
+ useDataSource={dsConfig.useDataSources[0]}
200
+ query={
201
+ {
202
+ where: "1=1",
203
+ outFields: ["*"],
204
+ returnGeometry: true
205
+ } as FeatureLayerQueryParams
206
+ }
207
+ widgetId={props.widgetId}
208
+ >
209
+ {(ds: DataSource) => {
210
+ if (ds && ds.getStatus() === DataSourceStatus.Loaded) {
211
+ setDatasources((prevDatasources) => {
212
+ // Avoids adding duplicates
213
+ if (!prevDatasources.includes(ds)) {
214
+ return [...prevDatasources, ds]
215
+ }
216
+ return prevDatasources
217
+ })
218
+ // Data source is loaded — populate calendar events
219
+ fillCalendarEvents(ds, dsConfig.asMutable({ deep: true }))
220
+ }
221
+ return null
222
+ }}
223
+ </DataSourceComponent>
224
+ ))}
225
+ </>
226
+ )}
227
+ </>
228
+ )
229
+ }
@@ -0,0 +1,419 @@
1
+ import {
2
+ React,
3
+ Immutable,
4
+ type UseDataSource,
5
+ DataSourceTypes,
6
+ utils,
7
+ type ImmutableObject,
8
+ css
9
+ } from "jimu-core"
10
+ import type { AllWidgetSettingProps } from "jimu-for-builder"
11
+ import { SettingRow, SettingSection } from "jimu-ui/advanced/setting-components"
12
+ import { Button, TextInput, Tabs, Tab, CollapsablePanel } from "jimu-ui"
13
+ import type { IMConfig, colorset, data } from "../config"
14
+ import {
15
+ DataSourceSelector,
16
+ FieldSelector
17
+ } from "jimu-ui/advanced/data-source-selector"
18
+
19
+ export default function Setting(props: AllWidgetSettingProps<IMConfig>) {
20
+ const { id, config } = props
21
+ const [activeTab, setActiveTab] = React.useState<string | undefined>(
22
+ config?.dataSets?.[0]?.id
23
+ )
24
+
25
+ // helper to get a mutable copy of datasets
26
+ const getDataSets = () =>
27
+ config?.dataSets ? config.dataSets.asMutable({ deep: true }) : []
28
+
29
+ const updateDataset = (datasetId: string, newData: Partial<any>) => {
30
+ const arr = getDataSets()
31
+ const idx = arr.findIndex((d: any) => d.id === datasetId)
32
+ if (idx === -1) return
33
+ arr[idx] = { ...arr[idx], ...newData }
34
+ const newConfig = (config || Immutable({})).set("dataSets", arr)
35
+ props.onSettingChange({ id, config: newConfig })
36
+ }
37
+
38
+ const addDataset = () => {
39
+ const newDataset = {
40
+ id: `ds_${utils.getUUID()}`,
41
+ labelField: undefined,
42
+ startDateField: undefined,
43
+ endDateField: undefined,
44
+ allDayField: undefined,
45
+ descriptionField: undefined,
46
+ colorsetField: undefined,
47
+ defaultEventColor: "#3788d8",
48
+ colorsets: [] as colorset[],
49
+ useDataSources: [] as UseDataSource[]
50
+ }
51
+ const arr = getDataSets()
52
+ arr.push(newDataset)
53
+ const newConfig = (config || Immutable({})).set("dataSets", arr)
54
+ props.onSettingChange({ id, config: newConfig })
55
+ }
56
+
57
+ const removeDataset = (datasetId: string) => {
58
+ const arr = getDataSets().filter((d: any) => d.id !== datasetId)
59
+ const newConfig = (config || Immutable({})).set("dataSets", arr)
60
+ props.onSettingChange({ id, config: newConfig })
61
+ }
62
+
63
+ const addValue = (datasetId: string) => {
64
+ const newValue: colorset = {
65
+ id: `view_${utils.getUUID()}`,
66
+ fieldValue: ``,
67
+ color: `#000000`
68
+ }
69
+ const arr = getDataSets()
70
+ const idx = arr.findIndex((d: any) => d.id === datasetId)
71
+ if (idx === -1) return
72
+ const ds = arr[idx]
73
+ ds.colorsets = ds.colorsets ? [...ds.colorsets, newValue] : [newValue]
74
+ updateDataset(datasetId, { colorsets: ds.colorsets })
75
+ }
76
+
77
+ const removeValue = (datasetId: string, viewId: string) => {
78
+ const arr = getDataSets()
79
+ const idx = arr.findIndex((d: any) => d.id === datasetId)
80
+ if (idx === -1) return
81
+ const ds = arr[idx]
82
+ const newcolorsets = (ds.colorsets || []).filter(
83
+ (v: any) => v.id !== viewId
84
+ )
85
+ updateDataset(datasetId, { colorsets: newcolorsets })
86
+ }
87
+
88
+ const updateValue = (
89
+ datasetId: string,
90
+ viewId: string,
91
+ newViewData: Partial<colorset>
92
+ ) => {
93
+ const arr = getDataSets()
94
+ const idx = arr.findIndex((d: any) => d.id === datasetId)
95
+ if (idx === -1) return
96
+ const ds = arr[idx]
97
+ const newcolorsets = (ds.colorsets || []).map((v: any) =>
98
+ v.id === viewId ? { ...v, ...newViewData } : v
99
+ )
100
+ updateDataset(datasetId, { colorsets: newcolorsets })
101
+ }
102
+
103
+ return (
104
+ <div className="view-layers-toggle-setting">
105
+ <SettingSection>
106
+ <SettingRow
107
+ flow={"no-wrap"}
108
+ level={1}
109
+ tag={"div"}
110
+ label={props.intl.formatMessage({
111
+ id: "dataSources",
112
+ defaultMessage: "Add Data Sources"
113
+ })}
114
+ >
115
+ <Button type="secondary" onClick={addDataset}>
116
+ {props.intl.formatMessage({
117
+ id: "Add Dataset",
118
+ defaultMessage: "Add Dataset"
119
+ })}
120
+ </Button>
121
+ </SettingRow>
122
+ </SettingSection>
123
+
124
+ {config?.dataSets && config.dataSets.length > 0 && (
125
+ <SettingSection title={props.intl.formatMessage({ id: "Datasets" })}>
126
+ <Tabs
127
+ value={activeTab}
128
+ onChange={(id) => {
129
+ setActiveTab(id)
130
+ }}
131
+ type="tabs"
132
+ keepMount
133
+ scrollable={true}
134
+ onClose={(id) => {
135
+ removeDataset(id)
136
+ }}
137
+ children={
138
+ config.dataSets.map(
139
+ (ds: ImmutableObject<data>, dsIndex: number) => (
140
+ <Tab
141
+ id={ds.id}
142
+ key={ds.id}
143
+ title={`Dataset ${dsIndex + 1}`}
144
+ closeable
145
+ >
146
+ <SettingRow label={"Data Source"} level={1} flow={"wrap"}>
147
+ <DataSourceSelector
148
+ types={Immutable([DataSourceTypes.FeatureLayer])}
149
+ mustUseDataSource={true}
150
+ isMultiple={false}
151
+ useDataSources={ds.useDataSources}
152
+ useDataSourcesEnabled={props.useDataSourcesEnabled}
153
+ onChange={(uds: UseDataSource[]) => {
154
+ updateDataset(ds.id, { useDataSources: uds })
155
+ }}
156
+ widgetId={props.id}
157
+ />
158
+ </SettingRow>
159
+ {ds.useDataSources?.length === 1 && (
160
+ <>
161
+ <SettingRow
162
+ label={"Select Label Field"}
163
+ level={2}
164
+ flow={"wrap"}
165
+ tag={"label"}
166
+ >
167
+ <FieldSelector
168
+ useDataSources={ds.useDataSources}
169
+ useDropdown={true}
170
+ isMultiple={false}
171
+ isDataSourceDropDownHidden={true}
172
+ onChange={(fields) => {
173
+ updateDataset(ds.id, {
174
+ labelField: fields?.[0]?.jimuName ?? null
175
+ })
176
+ }}
177
+ selectedFields={
178
+ ds?.labelField
179
+ ? Immutable([ds.labelField])
180
+ : ds?.useDataSources?.[0]?.fields
181
+ }
182
+ />
183
+ </SettingRow>
184
+
185
+ <SettingRow
186
+ label={"Start Date Field"}
187
+ level={1}
188
+ flow={"wrap"}
189
+ tag={"label"}
190
+ >
191
+ <FieldSelector
192
+ useDataSources={ds.useDataSources}
193
+ useDropdown={true}
194
+ isMultiple={false}
195
+ isDataSourceDropDownHidden={true}
196
+ onChange={(fields) => {
197
+ updateDataset(ds.id, {
198
+ startDateField: fields?.[0]?.jimuName ?? null
199
+ })
200
+ }}
201
+ selectedFields={
202
+ ds?.startDateField
203
+ ? Immutable([ds.startDateField])
204
+ : ds?.useDataSources?.[0]?.fields
205
+ }
206
+ />
207
+ </SettingRow>
208
+
209
+ <SettingRow
210
+ label={"End Date Field"}
211
+ level={2}
212
+ flow={"wrap"}
213
+ tag={"label"}
214
+ >
215
+ <FieldSelector
216
+ useDataSources={ds.useDataSources}
217
+ useDropdown={true}
218
+ isMultiple={false}
219
+ isDataSourceDropDownHidden={true}
220
+ onChange={(fields) => {
221
+ updateDataset(ds.id, {
222
+ endDateField: fields?.[0]?.jimuName ?? null
223
+ })
224
+ }}
225
+ selectedFields={
226
+ ds?.endDateField
227
+ ? Immutable([ds.endDateField])
228
+ : ds?.useDataSources?.[0]?.fields
229
+ }
230
+ />
231
+ </SettingRow>
232
+
233
+ <SettingRow
234
+ label={"All Day Field"}
235
+ level={2}
236
+ flow={"wrap"}
237
+ tag={"label"}
238
+ >
239
+ <FieldSelector
240
+ useDataSources={ds.useDataSources}
241
+ useDropdown={true}
242
+ isMultiple={false}
243
+ isDataSourceDropDownHidden={true}
244
+ onChange={(fields) => {
245
+ updateDataset(ds.id, {
246
+ allDayField: fields?.[0]?.jimuName ?? null
247
+ })
248
+ }}
249
+ selectedFields={
250
+ ds?.allDayField
251
+ ? Immutable([ds.allDayField])
252
+ : ds?.useDataSources?.[0]?.fields
253
+ }
254
+ />
255
+ </SettingRow>
256
+
257
+ <SettingRow
258
+ label={"Description Field"}
259
+ level={2}
260
+ flow={"wrap"}
261
+ tag={"label"}
262
+ >
263
+ <FieldSelector
264
+ useDataSources={ds.useDataSources}
265
+ useDropdown={true}
266
+ isMultiple={false}
267
+ isDataSourceDropDownHidden={true}
268
+ onChange={(fields) => {
269
+ updateDataset(ds.id, {
270
+ descriptionField: fields?.[0]?.jimuName ?? null
271
+ })
272
+ }}
273
+ selectedFields={
274
+ ds?.descriptionField
275
+ ? Immutable([ds.descriptionField])
276
+ : ds?.useDataSources?.[0]?.fields
277
+ }
278
+ />
279
+ </SettingRow>
280
+ {ds.labelField &&
281
+ ds.startDateField &&
282
+ ds.endDateField && (
283
+ <>
284
+ <CollapsablePanel
285
+ label={"Color Settings"}
286
+ defaultIsOpen={true}
287
+ >
288
+ <SettingRow
289
+ label={"Default Event Color"}
290
+ level={1}
291
+ flow={"no-wrap"}
292
+ tag={"label"}
293
+ >
294
+ <input
295
+ type="color"
296
+ value={ds?.defaultEventColor || "#3788d8"}
297
+ onChange={(e) => {
298
+ updateDataset(ds.id, {
299
+ defaultEventColor: e.target.value
300
+ })
301
+ }}
302
+ />
303
+ </SettingRow>
304
+ <SettingRow
305
+ label={"Color Field"}
306
+ level={2}
307
+ flow={"wrap"}
308
+ tag={"label"}
309
+ >
310
+ <FieldSelector
311
+ useDataSources={ds.useDataSources}
312
+ useDropdown={true}
313
+ isMultiple={false}
314
+ isDataSourceDropDownHidden={true}
315
+ onChange={(fields) => {
316
+ updateDataset(ds.id, {
317
+ colorsetField:
318
+ fields?.[0]?.jimuName ?? null
319
+ })
320
+ }}
321
+ selectedFields={
322
+ ds?.colorsetField
323
+ ? Immutable([ds.colorsetField])
324
+ : ds?.useDataSources?.[0]?.fields
325
+ }
326
+ />
327
+ </SettingRow>
328
+ {ds.colorsetField && (
329
+ <SettingRow
330
+ label={"Map field values to colors"}
331
+ level={2}
332
+ flow={"wrap"}
333
+ tag={"div"}
334
+ >
335
+ {(ds.colorsets || []).map(
336
+ (value: any, index: number) => (
337
+ <div
338
+ key={value.id}
339
+ className="value-config-container"
340
+ style={{ paddingBottom: "8px" }}
341
+ >
342
+ <SettingRow
343
+ label={`${props.intl.formatMessage({
344
+ id: "Color"
345
+ })} ${index + 1}`}
346
+ flow="no-wrap"
347
+ level={2}
348
+ >
349
+ <Button
350
+ size="sm"
351
+ type="tertiary"
352
+ onClick={() => {
353
+ removeValue(ds.id, value.id)
354
+ }}
355
+ >
356
+ Remove
357
+ </Button>
358
+ </SettingRow>
359
+ <SettingRow
360
+ flow="wrap"
361
+ label={props.intl.formatMessage({
362
+ id: "Value to Color"
363
+ })}
364
+ level={3}
365
+ css={css`
366
+ margin-top: 4px !important;
367
+ `}
368
+ >
369
+ <TextInput
370
+ size="sm"
371
+ value={value.fieldValue}
372
+ onChange={(e) => {
373
+ updateValue(ds.id, value.id, {
374
+ fieldValue:
375
+ e.currentTarget.value
376
+ })
377
+ }}
378
+ />
379
+ <input
380
+ type="color"
381
+ value={value.color || "#3788d8"}
382
+ onChange={(e) => {
383
+ updateValue(ds.id, value.id, {
384
+ color: e.currentTarget.value
385
+ })
386
+ }}
387
+ />
388
+ </SettingRow>
389
+ </div>
390
+ )
391
+ )}
392
+ <Button
393
+ type="primary"
394
+ className="w-100 mt-2"
395
+ onClick={() => {
396
+ addValue(ds.id)
397
+ }}
398
+ >
399
+ {props.intl.formatMessage({
400
+ id: "Add Color"
401
+ })}
402
+ </Button>
403
+ </SettingRow>
404
+ )}
405
+ </CollapsablePanel>
406
+ </>
407
+ )}
408
+ </>
409
+ )}
410
+ </Tab>
411
+ )
412
+ ) as any
413
+ }
414
+ />
415
+ </SettingSection>
416
+ )}
417
+ </div>
418
+ )
419
+ }
@@ -0,0 +1,4 @@
1
+ export default {
2
+ dataSources: 'Add Data Sources',
3
+ layers: 'Layers'
4
+ }
@@ -0,0 +1,100 @@
1
+ import { React, type ImmutableArray } from "jimu-core"
2
+ import _Widget from "../src/runtime/widget"
3
+ import { widgetRender, wrapWidget } from "jimu-for-test"
4
+ import { screen } from "@testing-library/react"
5
+ import "@testing-library/jest-dom"
6
+
7
+ /*
8
+ // Mock the DataSource so the widget receives a data source during tests
9
+ jest.mock("jimu-core", () => {
10
+ return {
11
+ ...jest.requireActual("jimu-core"),
12
+ DataSourceComponent: ({ onDataSourceCreated }: any) => {
13
+ React.useEffect(() => {
14
+ const fakeDs = () => {
15
+ mockFeatureLayer(featureLayer)
16
+ }
17
+ onDataSourceCreated && onDataSourceCreated(fakeDs)
18
+ }, [onDataSourceCreated])
19
+ return React.createElement("div", null, "mock-datasource")
20
+ }
21
+ }
22
+ })
23
+ */
24
+
25
+ const render = widgetRender()
26
+ describe("test Calendar Widget", () => {
27
+ it("handle unconfigured settings panel", () => {
28
+ const Widget = wrapWidget(_Widget, {
29
+ config: {}
30
+ })
31
+ const { queryByText, rerender } = render(<Widget widgetId="Widget_1" />)
32
+ expect(
33
+ queryByText("Please configure the Calendar widget in the settings panel.")
34
+ ).toBeTruthy()
35
+
36
+ rerender(
37
+ <Widget
38
+ widgetId="Widget_1"
39
+ config={{}}
40
+ useDataSources={
41
+ [
42
+ {
43
+ dataSourceId: "mock-datasource",
44
+ mainDataSourceId: "mock-datasource"
45
+ }
46
+ ] as unknown as ImmutableArray<any>
47
+ }
48
+ />
49
+ )
50
+ expect(
51
+ queryByText("Please configure the Calendar widget in the settings panel.")
52
+ ).toBeTruthy()
53
+ expect(queryByText("Month", { selector: "button" })).toBeFalsy()
54
+
55
+ rerender(
56
+ <Widget
57
+ widgetId="Widget_1"
58
+ config={[
59
+ {
60
+ labelField: "name",
61
+ startDateField: "start_date",
62
+ endDateField: "end_date",
63
+ allDayField: "all_day"
64
+ }
65
+ ]}
66
+ />
67
+ )
68
+ expect(
69
+ queryByText("Please configure the Calendar widget in the settings panel.")
70
+ ).toBeTruthy()
71
+ })
72
+
73
+ it("basic render with configured settings (not verifying events load)", () => {
74
+ const Widget = wrapWidget(_Widget, {
75
+ config: [
76
+ {
77
+ labelField: "label",
78
+ startDateField: "start_date",
79
+ endDateField: "end_date",
80
+ allDayField: "all_day",
81
+ useDataSources: [
82
+ {
83
+ dataSourceId: "mock-datasource",
84
+ mainDataSourceId: "mock-datasource"
85
+ }
86
+ ]
87
+ }
88
+ ]
89
+ })
90
+ render(<Widget widgetId="Widget_1" />)
91
+ //expect(screen.queryByText("mock-datasource")).toBeTruthy()
92
+ expect(screen.queryAllByText("Month", { selector: "button" })).toBeTruthy()
93
+ /*
94
+ const event = await screen.findByText("Event 1", {
95
+ selector: ".fc-event-title"
96
+ })
97
+ expect(event).toBeTruthy()
98
+ */
99
+ })
100
+ })
@@ -0,0 +1,188 @@
1
+ import type { MockFeatureLayerData } from "jimu-for-test"
2
+
3
+ const FEATURE_LAYER_URL = 'https://services3.arcgis.com/SMmkLJuWWI7vDDUq/arcgis/rest/services/Events/FeatureServer/0'
4
+
5
+ const NOW = new Date(Date.now()).toISOString()
6
+ const LATER = new Date(Date.now() + 60*60*1000).toISOString()
7
+
8
+ const SERVER_INFO = {
9
+ currentVersion: 10.71,
10
+ fullVersion: '10.7.1',
11
+ soapUrl: 'https://sunshinegis.maps.arcgis.com/arcgis/services',
12
+ secureSoapUrl: 'https://sunshinegis.maps.arcgis.com/arcgis/services',
13
+ authInfo: {
14
+ isTokenBasedSecurity: true,
15
+ tokenServicesUrl: 'https://sunshinegis.maps.arcgis.com/arcgis/tokens/',
16
+ shortLivedTokenValidity: 60
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Mocked point feature layer.
22
+ */
23
+ export const featureLayer: MockFeatureLayerData = {
24
+ url: FEATURE_LAYER_URL,
25
+ serverInfo: SERVER_INFO,
26
+ layerDefinition: {
27
+ currentVersion: 10.71,
28
+ id: 0,
29
+ name: 'Incidents',
30
+ type: 'Feature Layer',
31
+ geometryType: 'esriGeometryPoint',
32
+ objectIdField: 'objectid',
33
+ globalIdField: '',
34
+ displayField: 'req_type',
35
+ typeIdField: 'req_type',
36
+ subtypeField: '',
37
+ fields: [
38
+ {
39
+ name: 'objectid',
40
+ type: 'esriFieldTypeOID',
41
+ alias: 'Object ID',
42
+ domain: null,
43
+ editable: false,
44
+ nullable: false,
45
+ defaultValue: null,
46
+ modelName: 'OBJECTID'
47
+ },
48
+ {
49
+ name: 'label',
50
+ type: 'esriFieldTypeString',
51
+ alias: 'Label',
52
+ domain: null,
53
+ editable: false,
54
+ nullable: false,
55
+ defaultValue: null,
56
+ modelName: 'LABEL'
57
+ },
58
+ {
59
+ name: 'start_date',
60
+ type: 'esriFieldTypeDate',
61
+ alias: 'Start Date',
62
+ domain: null,
63
+ editable: false,
64
+ nullable: false,
65
+ defaultValue: null,
66
+ modelName: 'START_DATE'
67
+ },
68
+ {
69
+ name: 'end_date',
70
+ type: 'esriFieldTypeDate',
71
+ alias: 'End Date',
72
+ domain: null,
73
+ editable: false,
74
+ nullable: false,
75
+ defaultValue: null,
76
+ modelName: 'END_DATE'
77
+ },
78
+ {
79
+ name: 'all_day',
80
+ type: 'esriFieldTypeString',
81
+ alias: 'All Day',
82
+ domain: null,
83
+ editable: false,
84
+ nullable: true,
85
+ defaultValue: null,
86
+ modelName: 'ALL_DAY'
87
+ },
88
+ {
89
+ name: 'description',
90
+ type: 'esriFieldTypeString',
91
+ alias: 'Description',
92
+ domain: null,
93
+ editable: false,
94
+ nullable: true,
95
+ defaultValue: null,
96
+ modelName: 'DESCRIPTION'
97
+ },
98
+ {
99
+ name: 'color',
100
+ type: 'esriFieldTypeString',
101
+ alias: 'Color',
102
+ domain: null,
103
+ editable: false,
104
+ nullable: true,
105
+ defaultValue: null,
106
+ modelName: 'COLOR'
107
+ },
108
+ {
109
+ name: 'geometryField',
110
+ type: 'esriFieldTypeGeometry'
111
+ }
112
+ ],
113
+ advancedQueryCapabilities: {
114
+ useStandardizedQueries: true,
115
+ supportsStatistics: true,
116
+ supportsHavingClause: true,
117
+ supportsOrderBy: true,
118
+ supportsDistinct: true,
119
+ supportsCountDistinct: true,
120
+ supportsPagination: true,
121
+ supportsPaginationOnAggregatedQueries: true,
122
+ supportsTrueCurve: true,
123
+ supportsReturningQueryExtent: true,
124
+ supportsQueryWithDistance: true,
125
+ supportsSqlExpression: true
126
+ }
127
+ },
128
+ queries: [{
129
+ url: `${FEATURE_LAYER_URL}/query?f=json&where=1=1&outFields=*`,
130
+ result: {
131
+ fields: [{
132
+ name: 'objectid',
133
+ type: 'esriFieldTypeOID',
134
+ alias: 'Object ID'
135
+ },{
136
+ name: 'label',
137
+ type: 'esriFieldTypeString',
138
+ alias: 'Label'
139
+ },{
140
+ name: 'start_date',
141
+ type: 'esriFieldTypeDate',
142
+ alias: 'Start Date'
143
+ },{
144
+ name: 'end_date',
145
+ type: 'esriFieldTypeDate',
146
+ alias: 'End Date'
147
+ },{
148
+ name: 'all_day',
149
+ type: 'esriFieldTypeString',
150
+ alias: 'All Day'
151
+ },{
152
+ name: 'description',
153
+ type: 'esriFieldTypeString',
154
+ alias: 'Description'
155
+ },{
156
+ name: 'color',
157
+ type: 'esriFieldTypeString',
158
+ alias: 'Color'
159
+ }],
160
+ features: [{
161
+ attributes: {
162
+ objectid: 1,
163
+ label: 'Event 1',
164
+ start_date: NOW,
165
+ all_day: 'y',
166
+ description: 'Description for Event 1',
167
+ color: 'High'
168
+ }},{
169
+ attributes: {
170
+ objectid: 2,
171
+ label: 'Event 2',
172
+ start_date: NOW,
173
+ end_date: LATER,
174
+ all_day: 'n',
175
+ description: 'Description for Event 2',
176
+ color: 'Medium'
177
+ }},{attributes: {
178
+ objectid: 3,
179
+ label: 'Event 3',
180
+ start_date: 1622764800000,
181
+ all_day: 'y',
182
+ description: 'Description for Event 3',
183
+ color: 'Low'
184
+ }}
185
+ ]
186
+ }
187
+ }]
188
+ } as MockFeatureLayerData