@cratis/scene.blueprint.default 4.32.0 → 4.33.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.
Files changed (122) hide show
  1. package/ComponentName.ts +12 -0
  2. package/defaultBlueprintComponents.ts +5 -1
  3. package/dist/cjs/ComponentName.js +4 -0
  4. package/dist/cjs/ComponentName.js.map +1 -1
  5. package/dist/cjs/defaultBlueprint.js +1 -1
  6. package/dist/cjs/defaultBlueprintComponentMetadata.js +2 -2
  7. package/dist/cjs/defaultBlueprintComponents.js +10 -2
  8. package/dist/cjs/defaultBlueprintComponents.js.map +1 -1
  9. package/dist/cjs/foundation/ChartWrapper.js +520 -0
  10. package/dist/cjs/foundation/ChartWrapper.js.map +1 -0
  11. package/dist/cjs/foundation/ColumnChooser.js +156 -0
  12. package/dist/cjs/foundation/ColumnChooser.js.map +1 -0
  13. package/dist/cjs/foundation/FilterBuilder.js +307 -0
  14. package/dist/cjs/foundation/FilterBuilder.js.map +1 -0
  15. package/dist/cjs/foundation/SavedViewsMenu.js +199 -0
  16. package/dist/cjs/foundation/SavedViewsMenu.js.map +1 -0
  17. package/dist/cjs/foundation/chartGeometry.js +101 -0
  18. package/dist/cjs/foundation/chartGeometry.js.map +1 -0
  19. package/dist/cjs/foundation/columnModel.js +43 -0
  20. package/dist/cjs/foundation/columnModel.js.map +1 -0
  21. package/dist/cjs/foundation/filterModel.js +123 -0
  22. package/dist/cjs/foundation/filterModel.js.map +1 -0
  23. package/dist/cjs/foundation/foundationComponentMetadata.js +28 -0
  24. package/dist/cjs/foundation/foundationComponentMetadata.js.map +1 -1
  25. package/dist/cjs/foundation/foundationDescriptors.js +153 -0
  26. package/dist/cjs/foundation/foundationDescriptors.js.map +1 -1
  27. package/dist/cjs/foundation/foundationSamples.js +130 -0
  28. package/dist/cjs/foundation/foundationSamples.js.map +1 -1
  29. package/dist/cjs/foundation/foundationStrings.js +25 -0
  30. package/dist/cjs/foundation/foundationStrings.js.map +1 -1
  31. package/dist/cjs/foundation/useElementWidth.js +31 -0
  32. package/dist/cjs/foundation/useElementWidth.js.map +1 -0
  33. package/dist/cjs/foundation/viewModel.js +24 -0
  34. package/dist/cjs/foundation/viewModel.js.map +1 -0
  35. package/dist/cjs/index.js +38 -0
  36. package/dist/cjs/index.js.map +1 -1
  37. package/dist/esm/ComponentName.d.ts +5 -1
  38. package/dist/esm/ComponentName.d.ts.map +1 -1
  39. package/dist/esm/ComponentName.js +4 -0
  40. package/dist/esm/ComponentName.js.map +1 -1
  41. package/dist/esm/defaultBlueprint.js +1 -1
  42. package/dist/esm/defaultBlueprintComponentMetadata.js +2 -2
  43. package/dist/esm/defaultBlueprintComponents.d.ts.map +1 -1
  44. package/dist/esm/defaultBlueprintComponents.js +10 -2
  45. package/dist/esm/defaultBlueprintComponents.js.map +1 -1
  46. package/dist/esm/foundation/ChartWrapper.d.ts +5 -0
  47. package/dist/esm/foundation/ChartWrapper.d.ts.map +1 -0
  48. package/dist/esm/foundation/ChartWrapper.js +517 -0
  49. package/dist/esm/foundation/ChartWrapper.js.map +1 -0
  50. package/dist/esm/foundation/ColumnChooser.d.ts +3 -0
  51. package/dist/esm/foundation/ColumnChooser.d.ts.map +1 -0
  52. package/dist/esm/foundation/ColumnChooser.js +154 -0
  53. package/dist/esm/foundation/ColumnChooser.js.map +1 -0
  54. package/dist/esm/foundation/FilterBuilder.d.ts +3 -0
  55. package/dist/esm/foundation/FilterBuilder.d.ts.map +1 -0
  56. package/dist/esm/foundation/FilterBuilder.js +305 -0
  57. package/dist/esm/foundation/FilterBuilder.js.map +1 -0
  58. package/dist/esm/foundation/Foundation.stories.d.ts +16 -0
  59. package/dist/esm/foundation/Foundation.stories.d.ts.map +1 -1
  60. package/dist/esm/foundation/Foundation.stories.js +16 -0
  61. package/dist/esm/foundation/Foundation.stories.js.map +1 -1
  62. package/dist/esm/foundation/SavedViewsMenu.d.ts +4 -0
  63. package/dist/esm/foundation/SavedViewsMenu.d.ts.map +1 -0
  64. package/dist/esm/foundation/SavedViewsMenu.js +196 -0
  65. package/dist/esm/foundation/SavedViewsMenu.js.map +1 -0
  66. package/dist/esm/foundation/chartGeometry.d.ts +34 -0
  67. package/dist/esm/foundation/chartGeometry.d.ts.map +1 -0
  68. package/dist/esm/foundation/chartGeometry.js +93 -0
  69. package/dist/esm/foundation/chartGeometry.js.map +1 -0
  70. package/dist/esm/foundation/columnModel.d.ts +11 -0
  71. package/dist/esm/foundation/columnModel.d.ts.map +1 -0
  72. package/dist/esm/foundation/columnModel.js +38 -0
  73. package/dist/esm/foundation/columnModel.js.map +1 -0
  74. package/dist/esm/foundation/filterModel.d.ts +33 -0
  75. package/dist/esm/foundation/filterModel.d.ts.map +1 -0
  76. package/dist/esm/foundation/filterModel.js +112 -0
  77. package/dist/esm/foundation/filterModel.js.map +1 -0
  78. package/dist/esm/foundation/foundationComponentMetadata.d.ts.map +1 -1
  79. package/dist/esm/foundation/foundationComponentMetadata.js +28 -0
  80. package/dist/esm/foundation/foundationComponentMetadata.js.map +1 -1
  81. package/dist/esm/foundation/foundationDescriptors.d.ts.map +1 -1
  82. package/dist/esm/foundation/foundationDescriptors.js +154 -1
  83. package/dist/esm/foundation/foundationDescriptors.js.map +1 -1
  84. package/dist/esm/foundation/foundationSamples.d.ts.map +1 -1
  85. package/dist/esm/foundation/foundationSamples.js +130 -0
  86. package/dist/esm/foundation/foundationSamples.js.map +1 -1
  87. package/dist/esm/foundation/foundationStrings.d.ts +25 -0
  88. package/dist/esm/foundation/foundationStrings.d.ts.map +1 -1
  89. package/dist/esm/foundation/foundationStrings.js +25 -0
  90. package/dist/esm/foundation/foundationStrings.js.map +1 -1
  91. package/dist/esm/foundation/index.d.ts +9 -0
  92. package/dist/esm/foundation/index.d.ts.map +1 -1
  93. package/dist/esm/foundation/index.js +9 -0
  94. package/dist/esm/foundation/index.js.map +1 -1
  95. package/dist/esm/foundation/useElementWidth.d.ts +3 -0
  96. package/dist/esm/foundation/useElementWidth.d.ts.map +1 -0
  97. package/dist/esm/foundation/useElementWidth.js +29 -0
  98. package/dist/esm/foundation/useElementWidth.js.map +1 -0
  99. package/dist/esm/foundation/viewModel.d.ts +7 -0
  100. package/dist/esm/foundation/viewModel.d.ts.map +1 -0
  101. package/dist/esm/foundation/viewModel.js +22 -0
  102. package/dist/esm/foundation/viewModel.js.map +1 -0
  103. package/dist/esm/index.js +9 -0
  104. package/dist/esm/index.js.map +1 -1
  105. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  106. package/foundation/ChartWrapper.tsx +252 -0
  107. package/foundation/ColumnChooser.tsx +73 -0
  108. package/foundation/FilterBuilder.tsx +144 -0
  109. package/foundation/Foundation.stories.tsx +16 -0
  110. package/foundation/SavedViewsMenu.tsx +94 -0
  111. package/foundation/chartGeometry.ts +108 -0
  112. package/foundation/columnModel.ts +39 -0
  113. package/foundation/filterModel.ts +111 -0
  114. package/foundation/foundationComponentMetadata.ts +4 -0
  115. package/foundation/foundationDescriptors.ts +86 -1
  116. package/foundation/foundationSamples.ts +19 -0
  117. package/foundation/foundationStrings.ts +25 -0
  118. package/foundation/index.ts +9 -0
  119. package/foundation/useElementWidth.ts +31 -0
  120. package/foundation/viewModel.ts +21 -0
  121. package/package.json +8 -8
  122. package/shell/layout.css +285 -0
@@ -0,0 +1,108 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ /** The kinds of chart the wrapper draws (spec Part B 2.3: line, bar, stacked, donut, funnel). */
5
+ export enum ChartKind {
6
+ Line = 'line',
7
+ Bar = 'bar',
8
+ Stacked = 'stacked',
9
+ Donut = 'donut',
10
+ Funnel = 'funnel',
11
+ }
12
+
13
+ /** One named series of values, one per category. */
14
+ export interface ChartSeries {
15
+ name: string;
16
+ values: number[];
17
+ }
18
+
19
+ /** A rounded-up axis maximum and the tick values below it, so an axis reads 0, 25, 50, 75, 100 and not 0, 23.4, 46.8. */
20
+ export interface ChartScale {
21
+ maximum: number;
22
+ ticks: number[];
23
+ }
24
+
25
+ /** Builds a scale from zero to a rounded-up maximum of the values, in about `target` steps. */
26
+ export function niceScale(maximum: number, target = 4): ChartScale {
27
+ if (!Number.isFinite(maximum) || maximum <= 0) return { maximum: 1, ticks: [0, 1] };
28
+
29
+ const rough = maximum / target;
30
+ const magnitude = 10 ** Math.floor(Math.log10(rough));
31
+ const residual = rough / magnitude;
32
+ const step = (residual <= 1 ? 1 : residual <= 2 ? 2 : residual <= 5 ? 5 : 10) * magnitude;
33
+ const top = Math.ceil(maximum / step) * step;
34
+ const ticks: number[] = [];
35
+ for (let tick = 0; tick <= top + step / 2; tick += step) ticks.push(Number(tick.toFixed(10)));
36
+ return { maximum: top, ticks };
37
+ }
38
+
39
+ /** Reads series out of a property value, dropping anything that is not a named list of numbers, so a half-bound chart draws what it has. */
40
+ export function readSeries(value: unknown): ChartSeries[] {
41
+ if (!Array.isArray(value)) return [];
42
+
43
+ return value.flatMap((entry): ChartSeries[] => {
44
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return [];
45
+ const { name, values } = entry as Record<string, unknown>;
46
+ if (typeof name !== 'string' || !Array.isArray(values)) return [];
47
+ return [{ name, values: values.map(item => (typeof item === 'number' && Number.isFinite(item) ? item : 0)) }];
48
+ });
49
+ }
50
+
51
+ /** The extent of the values a chart of this kind has to fit: stacked series add up, the others are compared one by one. */
52
+ export function dataMaximum(kind: ChartKind, series: ChartSeries[], categoryCount: number): number {
53
+ if (kind === ChartKind.Stacked) {
54
+ return Math.max(0, ...Array.from({ length: categoryCount }, (_, index) => series.reduce((sum, item) => sum + Math.max(0, item.values[index] ?? 0), 0)));
55
+ }
56
+
57
+ return Math.max(0, ...series.flatMap(item => item.values));
58
+ }
59
+
60
+ /** A slice of a donut, in radians clockwise from the top. */
61
+ export interface DonutSlice {
62
+ index: number;
63
+ value: number;
64
+ share: number;
65
+ start: number;
66
+ end: number;
67
+ }
68
+
69
+ /** Divides the positive values into slices that together make one turn. */
70
+ export function donutSlices(values: number[]): DonutSlice[] {
71
+ const positive = values.map(value => Math.max(0, value));
72
+ const total = positive.reduce((sum, value) => sum + value, 0);
73
+ if (total === 0) return [];
74
+
75
+ let angle = 0;
76
+ return positive.flatMap((value, index) => {
77
+ if (value === 0) return [];
78
+ const share = value / total;
79
+ const slice = { index, value, share, start: angle, end: angle + share * Math.PI * 2 };
80
+ angle = slice.end;
81
+ return [slice];
82
+ });
83
+ }
84
+
85
+ /** The SVG path of a ring segment between two radii; a full turn is drawn as two halves, because one arc cannot start and end on the same point. */
86
+ export function ringPath(centerX: number, centerY: number, outer: number, inner: number, start: number, end: number): string {
87
+ const point = (radius: number, angle: number) => `${(centerX + radius * Math.sin(angle)).toFixed(2)} ${(centerY - radius * Math.cos(angle)).toFixed(2)}`;
88
+ if (end - start >= Math.PI * 2 - 1e-6) {
89
+ const middle = start + Math.PI;
90
+ return `${ringPath(centerX, centerY, outer, inner, start, middle)} ${ringPath(centerX, centerY, outer, inner, middle, end)}`;
91
+ }
92
+
93
+ const large = end - start > Math.PI ? 1 : 0;
94
+ return `M ${point(outer, start)} A ${outer} ${outer} 0 ${large} 1 ${point(outer, end)} L ${point(inner, end)} A ${inner} ${inner} 0 ${large} 0 ${point(inner, start)} Z`;
95
+ }
96
+
97
+ /** One stage of a funnel: a centered bar whose width is its share of the widest stage. */
98
+ export interface FunnelStage {
99
+ index: number;
100
+ value: number;
101
+ widthRatio: number;
102
+ }
103
+
104
+ /** Sizes the stages of a funnel against the largest value. */
105
+ export function funnelStages(values: number[]): FunnelStage[] {
106
+ const maximum = Math.max(0, ...values);
107
+ return values.map((value, index) => ({ index, value, widthRatio: maximum === 0 ? 0 : Math.max(0, value) / maximum }));
108
+ }
@@ -0,0 +1,39 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ /** One column a table could show. */
5
+ export interface ChooserColumn {
6
+ id: string;
7
+ label: string;
8
+ visible: boolean;
9
+ locked: boolean;
10
+ }
11
+
12
+ /** Reads columns out of a property value; a column is visible unless it says otherwise, and a locked column is always visible. */
13
+ export function readColumns(value: unknown): ChooserColumn[] {
14
+ if (!Array.isArray(value)) return [];
15
+
16
+ return value.flatMap((entry): ChooserColumn[] => {
17
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return [];
18
+ const { id, label, visible, locked } = entry as Record<string, unknown>;
19
+ if (typeof id !== 'string' || id.length === 0) return [];
20
+ return [{ id, label: typeof label === 'string' && label.length > 0 ? label : id, visible: locked === true || visible !== false, locked: locked === true }];
21
+ });
22
+ }
23
+
24
+ /** The ids of the visible columns, in order: what the chooser publishes and a table binds its columns to. */
25
+ export const visibleColumnIds = (columns: ChooserColumn[]): string[] => columns.filter(column => column.visible).map(column => column.id);
26
+
27
+ /** Moves a column one place up or down, staying where it is at either end. */
28
+ export function moveColumn(columns: ChooserColumn[], index: number, direction: -1 | 1): ChooserColumn[] {
29
+ const target = index + direction;
30
+ if (target < 0 || target >= columns.length) return columns;
31
+
32
+ const result = [...columns];
33
+ [result[index], result[target]] = [result[target], result[index]];
34
+ return result;
35
+ }
36
+
37
+ /** Shows or hides a column; a locked column cannot be hidden. */
38
+ export const toggleColumn = (columns: ChooserColumn[], index: number): ChooserColumn[] =>
39
+ columns.map((column, position) => (position === index && !column.locked ? { ...column, visible: !column.visible } : column));
@@ -0,0 +1,111 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ /** What a filterable field holds, which decides the operators offered for it. */
5
+ export enum FilterFieldType {
6
+ Text = 'text',
7
+ Number = 'number',
8
+ Date = 'date',
9
+ Select = 'select',
10
+ Boolean = 'boolean',
11
+ }
12
+
13
+ /** A field a filter can be built on. */
14
+ export interface FilterField {
15
+ name: string;
16
+ label: string;
17
+ type: FilterFieldType;
18
+ options: string[];
19
+ }
20
+
21
+ /** One test of a field. */
22
+ export interface FilterCondition {
23
+ field: string;
24
+ operator: string;
25
+ value: string;
26
+ }
27
+
28
+ /** Conditions and nested groups joined by AND or OR. */
29
+ export interface FilterGroup {
30
+ operator: 'and' | 'or';
31
+ conditions: FilterCondition[];
32
+ groups: FilterGroup[];
33
+ }
34
+
35
+ /** The deepest a group may nest; deeper filters are unreadable, and a runaway value is cut rather than rendered forever. */
36
+ export const maximumFilterDepth = 3;
37
+
38
+ /** An empty group. */
39
+ export const emptyFilter = (): FilterGroup => ({ operator: 'and', conditions: [], groups: [] });
40
+
41
+ /** The operators of each field type, in the order they are offered; the first is the default. */
42
+ export const filterOperators: Record<FilterFieldType, string[]> = {
43
+ [FilterFieldType.Text]: ['contains', 'equals', 'notEquals', 'startsWith', 'isEmpty'],
44
+ [FilterFieldType.Number]: ['equals', 'notEquals', 'greater', 'greaterOrEqual', 'less', 'lessOrEqual'],
45
+ [FilterFieldType.Date]: ['on', 'before', 'after'],
46
+ [FilterFieldType.Select]: ['is', 'isNot'],
47
+ [FilterFieldType.Boolean]: ['true', 'false'],
48
+ };
49
+
50
+ /** The operators that need no value to compare with. */
51
+ export const valuelessOperators = ['isEmpty', 'true', 'false'];
52
+
53
+ /** Reads fields out of a property value, dropping entries without a name, and defaulting what is unknown to text. */
54
+ export function readFilterFields(value: unknown): FilterField[] {
55
+ if (!Array.isArray(value)) return [];
56
+
57
+ return value.flatMap((entry): FilterField[] => {
58
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return [];
59
+ const { name, label, type, options } = entry as Record<string, unknown>;
60
+ if (typeof name !== 'string' || name.length === 0) return [];
61
+ return [{
62
+ name,
63
+ label: typeof label === 'string' && label.length > 0 ? label : name,
64
+ type: (Object.values(FilterFieldType) as string[]).includes(type as string) ? (type as FilterFieldType) : FilterFieldType.Text,
65
+ options: Array.isArray(options) ? options.filter((option): option is string => typeof option === 'string') : [],
66
+ }];
67
+ });
68
+ }
69
+
70
+ /** Reads a filter out of a property value, keeping the conditions on known fields with operators their type offers. */
71
+ export function normalizeFilter(value: unknown, fields: FilterField[], depth = 1): FilterGroup {
72
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return emptyFilter();
73
+
74
+ const { operator, conditions, groups } = value as Record<string, unknown>;
75
+ return {
76
+ operator: operator === 'or' ? 'or' : 'and',
77
+ conditions: Array.isArray(conditions) ? conditions.flatMap((entry): FilterCondition[] => {
78
+ if (typeof entry !== 'object' || entry === null) return [];
79
+ const condition = entry as Record<string, unknown>;
80
+ const field = fields.find(candidate => candidate.name === condition.field);
81
+ if (!field) return [];
82
+ const operators = filterOperators[field.type];
83
+ return [{
84
+ field: field.name,
85
+ operator: typeof condition.operator === 'string' && operators.includes(condition.operator) ? condition.operator : operators[0],
86
+ value: typeof condition.value === 'string' ? condition.value : typeof condition.value === 'number' ? String(condition.value) : '',
87
+ }];
88
+ }) : [],
89
+ groups: depth < maximumFilterDepth && Array.isArray(groups) ? groups.map(group => normalizeFilter(group, fields, depth + 1)) : [],
90
+ };
91
+ }
92
+
93
+ /** How many conditions are in force: a condition that needs a value and has none is not filtering anything. */
94
+ export function countActiveConditions(group: FilterGroup): number {
95
+ const own = group.conditions.filter(condition => valuelessOperators.includes(condition.operator) || condition.value.trim().length > 0).length;
96
+ return own + group.groups.reduce((sum, nested) => sum + countActiveConditions(nested), 0);
97
+ }
98
+
99
+ /** Applies a change to the group at a path of nested group indexes, leaving the rest as it was. */
100
+ export function updateGroupAt(root: FilterGroup, path: number[], change: (group: FilterGroup) => FilterGroup): FilterGroup {
101
+ if (path.length === 0) return change(root);
102
+
103
+ const [index, ...rest] = path;
104
+ return { ...root, groups: root.groups.map((group, position) => (position === index ? updateGroupAt(group, rest, change) : group)) };
105
+ }
106
+
107
+ /** A new condition on the first field, with that field's default operator. */
108
+ export function newCondition(fields: FilterField[]): FilterCondition {
109
+ const field = fields[0];
110
+ return { field: field?.name ?? '', operator: field ? filterOperators[field.type][0] : 'contains', value: '' };
111
+ }
@@ -14,4 +14,8 @@ export const foundationComponentMetadata: Record<string, ComponentMetadata> = {
14
14
  [ComponentName.StatCard]: m('Stat card', ComponentCategory.DataDisplay, 'gauge', ['kpi', 'metric', 'figure', 'number', 'dashboard']),
15
15
  [ComponentName.TrendChip]: m('Trend chip', ComponentCategory.DataDisplay, 'trending-up', ['delta', 'change', 'arrow', 'comparison']),
16
16
  [ComponentName.StatusChip]: m('Status chip', ComponentCategory.DataDisplay, 'badge-check', ['badge', 'state', 'tag', 'label']),
17
+ [ComponentName.ChartWrapper]: m('Chart', ComponentCategory.DataDisplay, 'chart-line', ['graph', 'line', 'bar', 'stacked', 'donut', 'pie', 'funnel', 'plot']),
18
+ [ComponentName.FilterBuilder]: m('Filter builder', ComponentCategory.Input, 'list-filter', ['filter', 'query', 'conditions', 'and or', 'search']),
19
+ [ComponentName.ColumnChooser]: m('Column chooser', ComponentCategory.Actions, 'columns-3', ['columns', 'fields', 'show hide', 'table']),
20
+ [ComponentName.SavedViewsMenu]: m('Saved views menu', ComponentCategory.Actions, 'bookmark', ['views', 'presets', 'saved filters']),
17
21
  };
@@ -2,10 +2,12 @@
2
2
  // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
3
 
4
4
  import { BindingSourceKind, ComponentDescriptor, PropertyDescriptor, PropertyEditorKind, PropertyValueType } from '@cratis/scene.model';
5
- import { componentRegistryKey } from '@cratis/scene.react';
5
+ import { componentRegistryKey, valueOutputProperty } from '@cratis/scene.react';
6
6
  import { ComponentName } from '../ComponentName';
7
7
  import { defaultBlueprintName } from '../packageName';
8
+ import { ChartKind } from './chartGeometry';
8
9
  import { ComponentState } from './ComponentState';
10
+ import { FilterFieldType } from './filterModel';
9
11
  import { NumberFormat } from './formatting';
10
12
  import { PermissionRule } from './PermissionRule';
11
13
  import { StatusTone } from './StatusChip';
@@ -115,5 +117,88 @@ export const foundationDescriptors: ComponentDescriptor[] = [
115
117
  }, StatusTone.Neutral, 'The tone picks the icon and the theme color.'),
116
118
  ],
117
119
  },
120
+ {
121
+ component: key(ComponentName.ChartWrapper),
122
+ displayName: 'Chart',
123
+ description: 'One chart - line, bar, stacked, donut or funnel - with a legend and a table of the same data. A donut and a funnel draw the first series.',
124
+ properties: [
125
+ text('title', 'Title', 'Content'),
126
+ choice('kind', 'Chart type', 'Appearance', {
127
+ [ChartKind.Line]: 'Line', [ChartKind.Bar]: 'Bar', [ChartKind.Stacked]: 'Stacked bar', [ChartKind.Donut]: 'Donut', [ChartKind.Funnel]: 'Funnel',
128
+ }, ChartKind.Line),
129
+ { path: 'categories', label: 'Categories', group: 'Data', valueType: PropertyValueType.Json, editorKind: PropertyEditorKind.StringList, default: [], acceptedBindingKinds: bindable, description: 'The label of each point, bar or slice, in order.' },
130
+ {
131
+ ...list('series', 'Series', 'Data', 'Series', [
132
+ text('name', 'Name', 'Data', undefined, { constraints: { required: true } }),
133
+ { path: 'values', label: 'Values', group: 'Data', valueType: PropertyValueType.Json, editorKind: PropertyEditorKind.NumberList, default: [], description: 'One value for each category.' },
134
+ ], 'What is plotted: a named list of values, one per category.'),
135
+ acceptedBindingKinds: bindable,
136
+ },
137
+ choice('format', 'Value format', 'Appearance', formats, NumberFormat.Number),
138
+ text('currency', 'Currency', 'Appearance', 'The ISO 4217 code when the format is currency.'),
139
+ ...standardStateProperties.filter(property => property.path !== 'label'),
140
+ ],
141
+ },
142
+ {
143
+ component: key(ComponentName.FilterBuilder),
144
+ displayName: 'Filter builder',
145
+ description: 'Conditions on fields, joined by AND or OR in groups that nest three deep. The filter is published as the value output; the component filters nothing itself.',
146
+ properties: [
147
+ {
148
+ ...list('fields', 'Fields', 'Data', 'Field', [
149
+ text('name', 'Name', 'Data', 'The result field the condition tests.', { constraints: { required: true }, editorKind: PropertyEditorKind.Field }),
150
+ text('label', 'Label', 'Content'),
151
+ choice('type', 'Type', 'Data', {
152
+ [FilterFieldType.Text]: 'Text', [FilterFieldType.Number]: 'Number', [FilterFieldType.Date]: 'Date', [FilterFieldType.Select]: 'Select', [FilterFieldType.Boolean]: 'Yes / no',
153
+ }, FilterFieldType.Text, 'Decides the operators offered.'),
154
+ { path: 'options', label: 'Options', group: 'Data', valueType: PropertyValueType.Json, editorKind: PropertyEditorKind.StringList, default: [], description: 'The choices of a select field.' },
155
+ ], 'What can be filtered.'),
156
+ acceptedBindingKinds: bindable,
157
+ },
158
+ { path: 'filter', label: 'Initial filter', group: 'Data', valueType: PropertyValueType.Object, acceptedBindingKinds: bindable, description: 'A filter to start from, usually bound to the active saved view.' },
159
+ rule('editPermission', 'Edit permission', 'Enabled lets the user change the filter; disabled shows it read-only.'),
160
+ valueOutputProperty(PropertyValueType.Object, 'The filter as the user has built it: { operator, conditions, groups }, published as it changes.'),
161
+ ...standardStateProperties,
162
+ ],
163
+ },
164
+ {
165
+ component: key(ComponentName.ColumnChooser),
166
+ displayName: 'Column chooser',
167
+ description: 'Which columns a table shows, and in what order. Publishes the ordered ids of the visible columns as the value output.',
168
+ properties: [
169
+ {
170
+ ...list('columns', 'Columns', 'Data', 'Column', [
171
+ text('id', 'Id', 'Data', 'The result field the column shows.', { constraints: { required: true }, editorKind: PropertyEditorKind.Field }),
172
+ text('label', 'Label', 'Content'),
173
+ flag('visible', 'Visible', 'Content', 'Whether the column starts shown.', { default: true }),
174
+ flag('locked', 'Always shown', 'Content', 'A locked column cannot be hidden: the identifying column is.'),
175
+ ], 'What could be shown.'),
176
+ acceptedBindingKinds: bindable,
177
+ },
178
+ valueOutputProperty(PropertyValueType.Json, 'The ids of the visible columns, in order, published as the user changes them.'),
179
+ ...standardStateProperties,
180
+ ],
181
+ },
182
+ {
183
+ component: key(ComponentName.SavedViewsMenu),
184
+ displayName: 'Saved views menu',
185
+ description: 'A list\'s saved views as a menu button, with the current view as its label. Publishes the chosen view id as the value output.',
186
+ properties: [
187
+ {
188
+ ...list('views', 'Views', 'Data', 'View', [
189
+ text('id', 'Id', 'Data', undefined, { constraints: { required: true } }),
190
+ text('label', 'Label', 'Content'),
191
+ text('description', 'Description', 'Content'),
192
+ ], 'The choices.'),
193
+ acceptedBindingKinds: bindable,
194
+ },
195
+ text('activeView', 'Active view', 'Data', 'The id of the view in force; bind it to start from a view.'),
196
+ rule('savePermission', 'Save permission', 'Whether "Save current view" works, refuses with a reason, or is not shown.'),
197
+ rule('managePermission', 'Manage permission', 'Whether "Manage views" works, refuses with a reason, or is not shown.'),
198
+ valueOutputProperty(PropertyValueType.String, 'The id of the chosen view, published as the user chooses.'),
199
+ { path: 'action', label: 'Requested action', group: 'Outputs', valueType: PropertyValueType.String, readOnly: true, output: true, description: '"save" or "manage" when the user asks for it; the host acts on it.' },
200
+ ...standardStateProperties,
201
+ ],
202
+ },
118
203
  ];
119
204
 
@@ -30,6 +30,25 @@ export const foundationSamples: Record<string, FoundationSample> = {
30
30
  [ComponentName.StatCard]: { properties: { label: 'Open pipeline', value: 1234567, format: 'currency', currency: 'USD', change: 12.5, comparison: 'last month', locale: 'en-US' }, data: ['$1,234,567'], stateful: true },
31
31
  [ComponentName.TrendChip]: { properties: { change: -3.2, goodDirection: 'down', comparison: 'last week', locale: 'en-US' }, data: [], stateful: false },
32
32
  [ComponentName.StatusChip]: { properties: { status: 'Awaiting approval', tone: 'warning' }, data: [], stateful: false },
33
+ [ComponentName.ChartWrapper]: {
34
+ properties: { title: 'Revenue by quarter', kind: 'line', categories: ['Q1', 'Q2', 'Q3', 'Q4'], series: [{ name: 'Revenue', values: [120, 180, 150, 240] }, { name: 'Target', values: [100, 150, 200, 220] }], locale: 'en-US' },
35
+ data: ['Revenue by quarter'], stateful: true,
36
+ },
37
+ [ComponentName.FilterBuilder]: {
38
+ properties: {
39
+ fields: [{ name: 'owner', label: 'Owner', type: 'text' }, { name: 'status', label: 'Status', type: 'select', options: ['Open', 'Won', 'Lost'] }, { name: 'value', label: 'Value', type: 'number' }],
40
+ filter: { operator: 'and', conditions: [{ field: 'status', operator: 'is', value: 'Open' }, { field: 'owner', operator: 'contains', value: 'Ada' }], groups: [] },
41
+ },
42
+ data: ['2 active filters'], stateful: true,
43
+ },
44
+ [ComponentName.ColumnChooser]: {
45
+ properties: { columns: [{ id: 'name', label: 'Name', locked: true }, { id: 'owner', label: 'Owner' }, { id: 'status', label: 'Status', visible: false }] },
46
+ data: ['3 shown'], stateful: true,
47
+ },
48
+ [ComponentName.SavedViewsMenu]: {
49
+ properties: { views: [{ id: 'all', label: 'All leads' }, { id: 'mine', label: 'Mine' }, { id: 'overdue', label: 'Overdue' }], activeView: 'mine' },
50
+ data: ['Mine'], stateful: true,
51
+ },
33
52
  };
34
53
 
35
54
  /** A scene element for a foundation component, populated with its sample unless properties are given, with the state forced when asked. */
@@ -45,14 +45,39 @@ export const foundationStrings = {
45
45
  filterOperator: 'Operator',
46
46
  filterValue: 'Value',
47
47
  filterClear: 'Clear filters',
48
+ filterCondition: 'Condition {n}',
49
+ filterGroup: 'Group {n}',
50
+ filterNoFields: 'There are no fields to filter on',
51
+ opContains: 'contains',
52
+ opEquals: 'equals',
53
+ opNotEquals: 'does not equal',
54
+ opStartsWith: 'starts with',
55
+ opIsEmpty: 'is empty',
56
+ opGreater: 'is greater than',
57
+ opGreaterOrEqual: 'is at least',
58
+ opLess: 'is less than',
59
+ opLessOrEqual: 'is at most',
60
+ opBefore: 'is before',
61
+ opAfter: 'is after',
62
+ opOn: 'is on',
63
+ opIs: 'is',
64
+ opIsNot: 'is not',
65
+ opTrue: 'is yes',
66
+ opFalse: 'is no',
48
67
  filterSummary: '{count} active filters',
68
+ filterSummaryOne: '1 active filter',
49
69
  columnsChoose: 'Columns',
50
70
  columnsLocked: 'always shown',
51
71
  columnsReset: 'Show all columns',
72
+ columnsMoveUp: 'Move {name} up',
73
+ columnsMoveDown: 'Move {name} down',
74
+ columnsShown: '{shown} of {total} shown',
75
+ columnsNone: 'There are no columns to choose from',
52
76
  viewsLabel: 'Saved views',
53
77
  viewsDefault: 'All items',
54
78
  viewsSave: 'Save current view',
55
79
  viewsManage: 'Manage views',
80
+ viewsNone: 'There are no saved views',
56
81
  checklistProgress: '{done} of {total} complete',
57
82
  checklistOwner: 'Owner',
58
83
  checklistDue: 'Due',
@@ -12,6 +12,15 @@ export * from './EmptyState';
12
12
  export * from './TrendChip';
13
13
  export * from './StatusChip';
14
14
  export * from './StatCard';
15
+ export * from './chartGeometry';
16
+ export * from './useElementWidth';
17
+ export * from './ChartWrapper';
18
+ export * from './filterModel';
19
+ export * from './FilterBuilder';
20
+ export * from './columnModel';
21
+ export * from './ColumnChooser';
22
+ export * from './viewModel';
23
+ export * from './SavedViewsMenu';
15
24
  export * from './foundationDescriptors';
16
25
  export * from './foundationComponentMetadata';
17
26
  export * from './foundationSamples';
@@ -0,0 +1,31 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ import { RefObject, useLayoutEffect, useState } from 'react';
5
+
6
+ /**
7
+ * The width of an element, kept up to date, so a chart can draw at the size it is shown at instead of scaling a fixed drawing -
8
+ * which would shrink its labels with it on a phone. Where there is no `ResizeObserver` (a test environment) the fallback is used.
9
+ */
10
+ export function useElementWidth(reference: RefObject<HTMLElement | null>, fallback: number): number {
11
+ const [width, setWidth] = useState(fallback);
12
+
13
+ useLayoutEffect(() => {
14
+ const element = reference.current;
15
+ if (!element) return;
16
+
17
+ const measure = () => {
18
+ const measured = Math.round(element.getBoundingClientRect().width);
19
+ if (measured > 0) setWidth(measured);
20
+ };
21
+
22
+ measure();
23
+ if (typeof ResizeObserver === 'undefined') return;
24
+
25
+ const observer = new ResizeObserver(measure);
26
+ observer.observe(element);
27
+ return () => observer.disconnect();
28
+ }, [reference]);
29
+
30
+ return width;
31
+ }
@@ -0,0 +1,21 @@
1
+ // Copyright (c) Cratis. All rights reserved.
2
+ // Licensed under the MIT license. See LICENSE file in the project root for full license information.
3
+
4
+ /** One saved view of a list: a named combination of filters, sort and columns. The definition lives with the application. */
5
+ export interface SavedView {
6
+ id: string;
7
+ label: string;
8
+ description?: string;
9
+ }
10
+
11
+ /** Reads saved views out of a property value, dropping entries without an id. */
12
+ export function readViews(value: unknown): SavedView[] {
13
+ if (!Array.isArray(value)) return [];
14
+
15
+ return value.flatMap((entry): SavedView[] => {
16
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return [];
17
+ const { id, label, description } = entry as Record<string, unknown>;
18
+ if (typeof id !== 'string' || id.length === 0) return [];
19
+ return [{ id, label: typeof label === 'string' && label.length > 0 ? label : id, ...(typeof description === 'string' ? { description } : {}) }];
20
+ });
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/scene.blueprint.default",
3
- "version": "4.32.0",
3
+ "version": "4.33.0",
4
4
  "description": "The default blueprint: application shell layouts, the screen and dialog templates that go inside them, the components that fill their slots, and two themes.",
5
5
  "keywords": [
6
6
  "cratis",
@@ -55,15 +55,15 @@
55
55
  "build-storybook": "storybook build"
56
56
  },
57
57
  "dependencies": {
58
- "@cratis/scene.engine": "4.32.0",
59
- "@cratis/scene.model": "4.32.0",
60
- "@cratis/scene.react": "4.32.0"
58
+ "@cratis/scene.engine": "4.33.0",
59
+ "@cratis/scene.model": "4.33.0",
60
+ "@cratis/scene.react": "4.33.0"
61
61
  },
62
62
  "devDependencies": {
63
- "@cratis/scene.engine": "4.32.0",
64
- "@cratis/scene.model": "4.32.0",
65
- "@cratis/scene.primereact": "4.32.0",
66
- "@cratis/scene.react": "4.32.0",
63
+ "@cratis/scene.engine": "4.33.0",
64
+ "@cratis/scene.model": "4.33.0",
65
+ "@cratis/scene.primereact": "4.33.0",
66
+ "@cratis/scene.react": "4.33.0",
67
67
  "@primereact/core": "11.2.0",
68
68
  "@primereact/headless": "11.2.0",
69
69
  "@primeuix/themes": "^3.0.1",