@nocobase/app-template-default 1.0.0-beta.69 → 1.0.0-beta.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,310 @@
1
+ import {
2
+ useMemo,
3
+ useRef,
4
+ useState,
5
+ type ReactElement,
6
+ type ReactNode,
7
+ } from 'react';
8
+ import { ChevronDownIcon, Settings2Icon } from 'lucide-react';
9
+ import { useTranslation } from '@nocobase/i18n/client';
10
+ import { Button } from '#components/ui/button';
11
+ import {
12
+ Popover,
13
+ PopoverContent,
14
+ PopoverTitle,
15
+ PopoverTrigger,
16
+ } from '#components/ui/popover';
17
+ import type { LifecycleDescription } from '@nocobase/lifecycle';
18
+ import { FlowDiagram, type FlowDiagramProps } from '#components/flow-diagram';
19
+ import type { FlowDiagramNode } from '#components/flow-layout';
20
+
21
+ import {
22
+ lifecycleDiagramGraph,
23
+ lifecycleConfigurationSelection,
24
+ lifecycleNodeConfigurationSelections,
25
+ type LifecycleConfigurationSelection,
26
+ type LifecycleConfigurationTarget,
27
+ type LifecycleDiagramConfiguration,
28
+ type LifecycleDiagramOptions,
29
+ } from './lifecycle-diagram-model.js';
30
+
31
+ export interface LifecycleDiagramProps
32
+ extends
33
+ LifecycleDiagramOptions,
34
+ Omit<
35
+ FlowDiagramProps,
36
+ | 'nodes'
37
+ | 'edges'
38
+ | 'renderNode'
39
+ | 'nodeActions'
40
+ | 'onNodeClick'
41
+ | 'onEdgeClick'
42
+ > {
43
+ readonly description: LifecycleDescription;
44
+ /** Opens the application-owned, authorized parameter editor for declared bindings. */
45
+ readonly configuration?: LifecycleDiagramConfiguration;
46
+ readonly stateActions?: (
47
+ state: LifecycleDescription['stateInfo'][number],
48
+ ) => ReactNode;
49
+ readonly renderState?: (
50
+ state: LifecycleDescription['stateInfo'][number],
51
+ node: FlowDiagramNode,
52
+ ) => ReactNode;
53
+ readonly onStateClick?: (
54
+ state: LifecycleDescription['stateInfo'][number],
55
+ ) => void;
56
+ readonly onTransitionClick?: (
57
+ transition: LifecycleDescription['transitions'][number],
58
+ ) => void;
59
+ }
60
+
61
+ /** Definitions are source-owned; clicking opens consumer-owned details, never fires a transition. */
62
+ export function LifecycleDiagram({
63
+ description,
64
+ currentState,
65
+ endLabel,
66
+ history,
67
+ visitedStates,
68
+ availableTransitions,
69
+ stateLabel,
70
+ transitionLabel,
71
+ renderState,
72
+ stateActions,
73
+ configuration,
74
+ onStateClick,
75
+ onTransitionClick,
76
+ ...props
77
+ }: LifecycleDiagramProps): ReactElement {
78
+ const sourceGraph = useMemo(
79
+ () =>
80
+ lifecycleDiagramGraph(description, {
81
+ currentState,
82
+ endLabel,
83
+ history,
84
+ visitedStates,
85
+ availableTransitions,
86
+ stateLabel,
87
+ transitionLabel,
88
+ }),
89
+ [
90
+ description,
91
+ currentState,
92
+ endLabel,
93
+ history,
94
+ visitedStates,
95
+ availableTransitions,
96
+ stateLabel,
97
+ transitionLabel,
98
+ ],
99
+ );
100
+ const graph = configuration
101
+ ? {
102
+ nodes: sourceGraph.nodes.map((node) => ({
103
+ ...node,
104
+ configurable: Boolean(
105
+ lifecycleNodeConfigurationSelections(
106
+ description,
107
+ node.id,
108
+ configuration.canConfigure,
109
+ ).length,
110
+ ),
111
+ })),
112
+ edges: sourceGraph.edges.map((edge) => ({
113
+ ...edge,
114
+ configurable:
115
+ !edge.decorative &&
116
+ Boolean(
117
+ lifecycleConfigurationSelection(
118
+ description,
119
+ {
120
+ kind: 'transition',
121
+ name: (JSON.parse(edge.id) as string[])[0],
122
+ },
123
+ configuration.canConfigure,
124
+ ),
125
+ ),
126
+ })),
127
+ }
128
+ : sourceGraph;
129
+ return (
130
+ <FlowDiagram
131
+ direction='DOWN'
132
+ initialMinZoom={0.7}
133
+ autoHeight
134
+ {...props}
135
+ {...graph}
136
+ nodeActions={(node) => {
137
+ const state = description.stateInfo.find(
138
+ (state) => state.name === node.id,
139
+ );
140
+ if (!state) return undefined;
141
+ const actions = stateActions?.(state);
142
+ const selections =
143
+ configuration &&
144
+ lifecycleNodeConfigurationSelections(
145
+ description,
146
+ state.name,
147
+ configuration.canConfigure,
148
+ );
149
+ if (!selections?.length || !configuration) return actions;
150
+ return (
151
+ <div className='nodrag nopan flex items-center gap-1'>
152
+ {actions}
153
+ <NodeConfigurationActions
154
+ configuration={configuration}
155
+ selections={selections}
156
+ state={{ kind: 'state', name: state.name }}
157
+ title={(target) => {
158
+ if (target.kind === 'state')
159
+ return stateLabel?.(state) ?? state.title;
160
+ const transition = description.transitions.find(
161
+ (item) => item.name === target.name,
162
+ )!;
163
+ return transitionLabel?.(transition) ?? transition.title;
164
+ }}
165
+ />
166
+ </div>
167
+ );
168
+ }}
169
+ renderNode={(node) => {
170
+ const state = description.stateInfo.find(
171
+ (state) => state.name === node.id,
172
+ );
173
+ return state && renderState ? renderState(state, node) : node.label;
174
+ }}
175
+ onNodeClick={(node) => {
176
+ const state = description.stateInfo.find(
177
+ (state) => state.name === node.id,
178
+ );
179
+ if (state) onStateClick?.(state);
180
+ }}
181
+ onEdgeClick={(edge) => {
182
+ if (edge.decorative) return;
183
+ const [name] = JSON.parse(edge.id) as string[];
184
+ const transition = description.transitions.find(
185
+ (item) => item.name === name,
186
+ );
187
+ if (!transition) return;
188
+ const selection =
189
+ configuration &&
190
+ lifecycleConfigurationSelection(
191
+ description,
192
+ { kind: 'transition', name: transition.name },
193
+ configuration.canConfigure,
194
+ );
195
+ if (selection && configuration) configuration.onConfigure(selection);
196
+ else onTransitionClick?.(transition);
197
+ }}
198
+ />
199
+ );
200
+ }
201
+
202
+ /** Reuse the authoritative editor; this chooser never invents or saves values. */
203
+ function NodeConfigurationActions({
204
+ configuration,
205
+ selections,
206
+ state,
207
+ title,
208
+ }: {
209
+ readonly configuration: LifecycleDiagramConfiguration;
210
+ readonly selections: readonly LifecycleConfigurationSelection[];
211
+ readonly state: LifecycleConfigurationTarget;
212
+ readonly title: (target: LifecycleConfigurationTarget) => string;
213
+ }): ReactElement {
214
+ const { t } = useTranslation();
215
+ const [open, setOpen] = useState(false);
216
+ const pendingRef = useRef<LifecycleConfigurationSelection | undefined>(
217
+ undefined,
218
+ );
219
+ const first = selections[0];
220
+ const direct =
221
+ first.target.kind === 'state' || selections.length === 1
222
+ ? first
223
+ : undefined;
224
+ const choices = direct ? selections.slice(1) : selections;
225
+ const label = t('flowDiagram.configure', { defaultValue: 'Configure' });
226
+ const className =
227
+ 'h-6 border border-primary/15 bg-primary/5 px-1.5 text-xs text-primary hover:bg-primary/10';
228
+ return (
229
+ <>
230
+ {direct && (
231
+ <Button
232
+ type='button'
233
+ variant='ghost'
234
+ size='sm'
235
+ className={className}
236
+ onClick={(event) => {
237
+ event.stopPropagation();
238
+ configuration.onConfigure(direct);
239
+ }}
240
+ >
241
+ <Settings2Icon aria-hidden />
242
+ {configuration.label(direct.target)}
243
+ </Button>
244
+ )}
245
+ {choices.length > 0 && (
246
+ <Popover
247
+ open={open}
248
+ onOpenChange={(nextOpen) => {
249
+ if (nextOpen) pendingRef.current = undefined;
250
+ setOpen(nextOpen);
251
+ }}
252
+ onOpenChangeComplete={(nextOpen) => {
253
+ // Open the editor only after the chooser has closed and restored focus.
254
+ // Otherwise its focus restoration can steal focus from the editor.
255
+ if (!nextOpen && pendingRef.current) {
256
+ const selection = pendingRef.current;
257
+ pendingRef.current = undefined;
258
+ configuration.onConfigure(selection);
259
+ }
260
+ }}
261
+ >
262
+ <PopoverTrigger
263
+ render={
264
+ <Button
265
+ type='button'
266
+ variant='ghost'
267
+ size='sm'
268
+ className={className}
269
+ aria-label={`${label}: ${title(state)}`}
270
+ onClick={(event) => event.stopPropagation()}
271
+ />
272
+ }
273
+ >
274
+ {!direct && (
275
+ <>
276
+ <Settings2Icon aria-hidden />
277
+ {label}
278
+ </>
279
+ )}
280
+ <ChevronDownIcon aria-hidden />
281
+ </PopoverTrigger>
282
+ <PopoverContent
283
+ align='end'
284
+ className='w-auto max-w-[min(var(--container-sm),var(--available-width))]'
285
+ onClick={(event) => event.stopPropagation()}
286
+ >
287
+ <PopoverTitle className='sr-only'>
288
+ {label}: {title(state)}
289
+ </PopoverTitle>
290
+ {choices.map((selection) => (
291
+ <Button
292
+ key={`${selection.target.kind}:${selection.target.name}`}
293
+ type='button'
294
+ variant='ghost'
295
+ className='h-auto justify-start whitespace-normal text-left'
296
+ onClick={() => {
297
+ pendingRef.current = selection;
298
+ setOpen(false);
299
+ }}
300
+ >
301
+ {configuration.label(selection.target)} ·{' '}
302
+ {title(selection.target)}
303
+ </Button>
304
+ ))}
305
+ </PopoverContent>
306
+ </Popover>
307
+ )}
308
+ </>
309
+ );
310
+ }
@@ -126,7 +126,7 @@ function LevelControl({
126
126
  if (levels.length === 0) return null;
127
127
  if (levels.length === 1)
128
128
  return (
129
- <span className='block truncate px-2.5 text-sm text-muted-foreground'>
129
+ <span className='flex min-h-7 min-w-0 items-center px-2 text-xs text-muted-foreground'>
130
130
  {levels[0]?.label}
131
131
  </span>
132
132
  );
@@ -141,7 +141,7 @@ function LevelControl({
141
141
  >
142
142
  <SelectTrigger
143
143
  size='sm'
144
- className='h-6 w-auto max-w-48 gap-1 border-0 bg-muted/50 px-2 text-xs shadow-none'
144
+ className='h-7 w-full min-w-0 max-w-full flex-1 gap-1 border-0 bg-muted/50 px-2 text-xs shadow-none'
145
145
  aria-label={label}
146
146
  >
147
147
  <SelectValue />
@@ -162,6 +162,7 @@ function LevelControl({
162
162
  function PermissionRow({
163
163
  row,
164
164
  controlId,
165
+ showScopeLabels = false,
165
166
  readOnly,
166
167
  labels,
167
168
  onEnabledChange,
@@ -169,6 +170,7 @@ function PermissionRow({
169
170
  }: {
170
171
  readonly row: PermissionEditorRow;
171
172
  readonly controlId: string;
173
+ readonly showScopeLabels?: boolean;
172
174
  readonly readOnly: boolean;
173
175
  readonly labels: PermissionEditorLabels;
174
176
  readonly onEnabledChange: (rowId: string, enabled: boolean) => void;
@@ -179,7 +181,7 @@ function PermissionRow({
179
181
  data-slot='permission-row'
180
182
  className='flex min-w-0 flex-col gap-1 rounded-md px-2 py-1.5'
181
183
  >
182
- <div className='flex min-h-6 min-w-0 flex-wrap items-center gap-2'>
184
+ <div className='grid min-h-6 min-w-0 grid-cols-[auto_auto_minmax(0,1fr)] items-center gap-x-2 @2xl:grid-cols-[auto_minmax(0,1fr)] gap-y-1'>
183
185
  <Checkbox
184
186
  id={controlId}
185
187
  checked={row.enabled}
@@ -189,35 +191,45 @@ function PermissionRow({
189
191
  />
190
192
  <label
191
193
  htmlFor={controlId}
192
- className='min-w-0 cursor-pointer text-sm leading-5 font-medium break-words'
194
+ className='min-h-6 min-w-0 max-w-36 cursor-pointer text-sm leading-6 font-medium break-words @2xl:max-w-none'
193
195
  >
194
196
  {row.label}
195
197
  </label>
196
198
  {row.enabled && row.levels ? (
197
- <LevelControl
198
- label={labels.levelFor(row.label)}
199
- levels={row.levels}
200
- value={row.level ?? ''}
201
- disabled={readOnly}
202
- onChange={(level) => onLevelChange(row.id, level)}
203
- />
199
+ <div className='col-start-3 min-w-0 @2xl:col-start-2'>
200
+ <LevelControl
201
+ label={labels.levelFor(row.label)}
202
+ levels={row.levels}
203
+ value={row.level ?? ''}
204
+ disabled={readOnly}
205
+ onChange={(level) => onLevelChange(row.id, level)}
206
+ />
207
+ </div>
204
208
  ) : null}
205
209
  {row.enabled &&
206
210
  row.scopes?.map((scope) => (
207
- <div key={scope.id} className='flex min-w-0 items-center gap-1'>
208
- {row.scopes && row.scopes.length > 1 && (
209
- <span className='text-xs text-muted-foreground'>
211
+ <div
212
+ key={scope.id}
213
+ className={cn(
214
+ 'col-start-3 grid min-w-0 gap-1 @2xl:col-start-2',
215
+ showScopeLabels && 'col-span-2 col-start-2 @2xl:col-span-1',
216
+ )}
217
+ >
218
+ {(showScopeLabels || (row.scopes && row.scopes.length > 1)) && (
219
+ <span className='text-xs leading-5 text-muted-foreground'>
210
220
  {scope.label}
211
221
  </span>
212
222
  )}
213
- <LevelControl
214
- label={labels.levelFor(`${row.label} · ${scope.label}`)}
215
- levels={scope.levels}
216
- value={scope.level ?? ''}
217
- disabled={readOnly}
218
- onChange={(level) => onLevelChange(scope.id, level)}
219
- />
220
- {scope.configuration}
223
+ <div className='flex min-h-7 min-w-0 items-center gap-1'>
224
+ <LevelControl
225
+ label={labels.levelFor(`${row.label} · ${scope.label}`)}
226
+ levels={scope.levels}
227
+ value={scope.level ?? ''}
228
+ disabled={readOnly}
229
+ onChange={(level) => onLevelChange(scope.id, level)}
230
+ />
231
+ {scope.configuration}
232
+ </div>
221
233
  </div>
222
234
  ))}
223
235
  </div>
@@ -280,7 +292,7 @@ export function PermissionEditor({
280
292
  return (
281
293
  <div className='@container flex min-w-0 flex-col overflow-hidden rounded-xl border bg-card'>
282
294
  <div className='flex flex-wrap items-center gap-3 border-b p-3'>
283
- <div className='relative min-w-0 flex-1'>
295
+ <div className='relative min-w-48 flex-1'>
284
296
  <SearchIcon
285
297
  aria-hidden
286
298
  className='pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground'
@@ -296,7 +308,7 @@ export function PermissionEditor({
296
308
  </div>
297
309
  <label
298
310
  htmlFor={`${id}-enabled`}
299
- className='flex items-center gap-2 text-sm'
311
+ className='flex shrink-0 items-center gap-2 text-sm whitespace-nowrap'
300
312
  >
301
313
  <Switch
302
314
  id={`${id}-enabled`}
@@ -322,7 +334,7 @@ export function PermissionEditor({
322
334
  <div className='flex max-h-128 min-w-0 flex-col overflow-auto @2xl:flex-row'>
323
335
  <nav
324
336
  aria-label={words.navigation}
325
- className='flex shrink-0 gap-1 overflow-x-auto border-b bg-muted/25 p-2 @2xl:sticky @2xl:top-0 @2xl:max-h-128 @2xl:w-48 @2xl:self-start @2xl:flex-col @2xl:border-r @2xl:border-b-0'
337
+ className='flex shrink-0 gap-1 overflow-x-auto border-b bg-muted/25 p-2 @2xl:sticky @2xl:top-0 @2xl:max-h-128 @2xl:w-64 @2xl:self-start @2xl:flex-col @2xl:border-r @2xl:border-b-0'
326
338
  >
327
339
  {sections.map((section) => {
328
340
  const rows = section.groups.flatMap((group) => group.rows);
@@ -338,13 +350,18 @@ export function PermissionEditor({
338
350
  setEnabledOnly(false);
339
351
  }}
340
352
  className={cn(
341
- 'h-auto shrink-0 justify-between gap-4 px-3 py-2.5 text-left whitespace-normal',
353
+ 'h-auto shrink-0 justify-between gap-3 px-3 py-2 text-left whitespace-nowrap @2xl:w-full @2xl:min-w-0',
342
354
  section.id === active &&
343
355
  'bg-primary/10 text-primary hover:bg-primary/15',
344
356
  )}
345
357
  >
346
- <span>{section.title}</span>
347
- <span className='rounded-md bg-background px-1.5 py-0.5 text-xs tabular-nums'>
358
+ <span
359
+ className='min-w-0 @2xl:flex-1 @2xl:truncate'
360
+ title={section.title}
361
+ >
362
+ {section.title}
363
+ </span>
364
+ <span className='shrink-0 rounded-md bg-background px-1.5 py-0.5 text-xs tabular-nums'>
348
365
  {rows.filter((row) => row.enabled).length}/{rows.length}
349
366
  </span>
350
367
  </Button>
@@ -357,6 +374,9 @@ export function PermissionEditor({
357
374
  const original =
358
375
  sections.find((item) => item.id === section.id) ?? section;
359
376
  const singleResource = original.groups.length === 1;
377
+ const showScopeLabels = original.groups.some((group) =>
378
+ group.rows.some((row) => (row.scopes?.length ?? 0) > 1),
379
+ );
360
380
  const keys = [
361
381
  ...new Set(
362
382
  original.groups
@@ -369,16 +389,30 @@ export function PermissionEditor({
369
389
  ...common.filter((key) => keys.includes(key)),
370
390
  ...keys.filter((key) => !common.includes(key)),
371
391
  ];
372
- // Dense or unclassified catalogs keep the wrapping layout instead of hiding actions.
373
392
  const aligned =
374
393
  columns.length > 0 &&
375
- columns.length <= 7 &&
394
+ columns.length <= 4 &&
376
395
  original.groups.every(
377
396
  (group) =>
378
397
  group.rows.every((row) => row.actionKey) &&
379
398
  new Set(group.rows.map((row) => row.actionKey)).size ===
380
399
  group.rows.length,
381
400
  );
401
+ // Keep action columns only when each has room for its label and scope.
402
+ const alignment = [
403
+ '',
404
+ '@3xl:grid-cols-(--permission-columns)',
405
+ '@3xl:grid-cols-(--permission-columns)',
406
+ '@5xl:grid-cols-(--permission-columns)',
407
+ '@7xl:grid-cols-(--permission-columns)',
408
+ ][columns.length];
409
+ const placement = [
410
+ '',
411
+ '@3xl:col-start-(--permission-column) @3xl:row-start-1',
412
+ '@3xl:col-start-(--permission-column) @3xl:row-start-1',
413
+ '@5xl:col-start-(--permission-column) @5xl:row-start-1',
414
+ '@7xl:col-start-(--permission-column) @7xl:row-start-1',
415
+ ][columns.length];
382
416
  return (
383
417
  <section
384
418
  key={section.id}
@@ -430,11 +464,11 @@ export function PermissionEditor({
430
464
  return (
431
465
  <div
432
466
  key={group.id}
433
- className='grid min-w-0 gap-2 p-3 @xl:grid-cols-[minmax(8rem,1fr)_minmax(0,4fr)]'
467
+ className='grid min-w-0 gap-2 p-3 @3xl:grid-cols-[9rem_minmax(0,1fr)]'
434
468
  data-slot='permission-resource'
435
469
  >
436
- <div className='flex min-w-0 flex-col justify-center gap-1'>
437
- <h3 className='text-sm font-medium break-words'>
470
+ <div className='flex min-w-0 flex-col gap-1 py-1.5'>
471
+ <h3 className='min-h-6 text-sm leading-6 font-medium break-words'>
438
472
  {group.title ?? section.title}
439
473
  </h3>
440
474
  {group.hint && (
@@ -442,7 +476,12 @@ export function PermissionEditor({
442
476
  {group.hint}
443
477
  </p>
444
478
  )}
445
- <div className='flex items-center gap-2 text-xs text-muted-foreground'>
479
+ <div
480
+ className={cn(
481
+ 'flex min-h-7 items-center gap-2 text-xs text-muted-foreground',
482
+ showScopeLabels && '@3xl:mt-6',
483
+ )}
484
+ >
446
485
  {onEnabledBatchChange &&
447
486
  totalRows.length > 1 &&
448
487
  selectable.length > 0 && (
@@ -514,9 +553,8 @@ export function PermissionEditor({
514
553
  </div>
515
554
  <div
516
555
  className={cn(
517
- 'flex min-w-0 flex-wrap items-start gap-x-3 gap-y-1',
518
- aligned &&
519
- '@4xl:grid @4xl:grid-cols-(--permission-columns) @4xl:gap-x-1',
556
+ 'grid min-w-0 grid-cols-[repeat(auto-fill,minmax(min(100%,14rem),1fr))] items-start gap-x-2 gap-y-1',
557
+ aligned && alignment,
520
558
  )}
521
559
  style={
522
560
  {
@@ -524,42 +562,42 @@ export function PermissionEditor({
524
562
  } as CSSProperties
525
563
  }
526
564
  >
527
- {(aligned
528
- ? [...group.rows].sort(
529
- (a, b) =>
530
- columns.indexOf(a.actionKey ?? '') -
531
- columns.indexOf(b.actionKey ?? ''),
532
- )
533
- : group.rows
534
- ).map((row) => (
535
- <div
536
- key={row.id}
537
- className={cn(
538
- 'min-w-0',
539
- aligned &&
540
- '@4xl:col-start-(--permission-column) @4xl:row-start-1',
541
- )}
542
- style={
543
- {
544
- '--permission-column':
545
- columns.indexOf(row.actionKey ?? '') + 1,
546
- } as CSSProperties
547
- }
548
- >
549
- <PermissionRow
550
- controlId={`${id}-${row.id}`}
551
- row={row}
552
- readOnly={readOnly}
553
- labels={words}
554
- onEnabledChange={(rowId, on) => {
555
- if (enabledOnly && !on)
556
- searchRef.current?.focus();
557
- onEnabledChange(rowId, on);
558
- }}
559
- onLevelChange={onLevelChange}
560
- />
561
- </div>
562
- ))}
565
+ {[...group.rows]
566
+ .sort(
567
+ (a, b) =>
568
+ columns.indexOf(a.actionKey ?? '') -
569
+ columns.indexOf(b.actionKey ?? ''),
570
+ )
571
+ .map((row) => (
572
+ <div
573
+ key={row.id}
574
+ className={cn(
575
+ 'min-w-0',
576
+ aligned && placement,
577
+ )}
578
+ style={
579
+ {
580
+ '--permission-column':
581
+ columns.indexOf(row.actionKey ?? '') +
582
+ 1,
583
+ } as CSSProperties
584
+ }
585
+ >
586
+ <PermissionRow
587
+ controlId={`${id}-${row.id}`}
588
+ row={row}
589
+ showScopeLabels={showScopeLabels}
590
+ readOnly={readOnly}
591
+ labels={words}
592
+ onEnabledChange={(rowId, on) => {
593
+ if (enabledOnly && !on)
594
+ searchRef.current?.focus();
595
+ onEnabledChange(rowId, on);
596
+ }}
597
+ onLevelChange={onLevelChange}
598
+ />
599
+ </div>
600
+ ))}
563
601
  </div>
564
602
  </div>
565
603
  );