Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/core/src/methodology/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,8 @@ fn rewrite_selector_ids(select: &mut Selector, renames: &HashMap<String, String>
/// its `value` is vocabulary (an enum value like `in_review` may spell
/// anything); `content` filters match body text; `relationship` filters name
/// a built-in edge kind and a literal node id; sort fields are field names;
/// the view config's `kanban.groupBy` is a field name. None of those is a
/// the view config's `kanban.groupBy` is a field name, and its
/// `kanban.columnOrder` maps field names to enum values. None of those is a
/// schema id.
///
/// A `metadata` filter on `node_type` is the exception: its value *is* a type
Expand Down
48 changes: 46 additions & 2 deletions packages/core/src/methodology/linear.rs
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,9 @@ fn blocker_gate() -> PlayStep {
///
/// Grouped by the inherited `status`, so the columns are the extended
/// vocabulary (`backlog`, `triage`, `in_review` alongside the base values)
/// rather than anything this view has to declare.
/// rather than anything this view has to declare. Their order is declared:
/// the enum lists the base values before the extended ones, and the board
/// reads as a workflow, left to right.
fn issues_by_status_view() -> ViewStep {
ViewStep {
view_id: ISSUES_BY_STATUS_ID,
Expand All @@ -563,11 +565,25 @@ fn issues_by_status_view() -> ViewStep {
},
view_config: json!({
"lastView": "kanban",
"kanban": { "groupBy": "status" },
"kanban": {
"groupBy": "status",
"columnOrder": { "status": ISSUE_STATUS_COLUMN_ORDER },
},
}),
}
}

/// The Issues by Status board's columns, in workflow order.
const ISSUE_STATUS_COLUMN_ORDER: [&str; 7] = [
"triage",
"backlog",
"open",
"in_progress",
"in_review",
"done",
"cancelled",
];

/// Cycles, most recent first.
///
/// A table rather than a board: a cycle has no stored status to group by
Expand Down Expand Up @@ -636,6 +652,34 @@ mod tests {
}
}

/// The viewer ignores an ordered value the enum does not have and appends
/// one the order leaves out, so a typo or a status added later would
/// misplace a column without failing anything.
#[test]
fn the_issue_board_orders_every_status_and_nothing_else() {
let ext = issue_status_values();
let mut statuses: Vec<&str> = vec!["open", "in_progress", "done", "cancelled"];
statuses.extend(
ext.params["add_field_values"][0]["values"]
.as_array()
.expect("values array")
.iter()
.map(|v| v["value"].as_str().expect("value")),
);
statuses.sort_unstable();

let view = issues_by_status_view();
let mut ordered: Vec<&str> = view.view_config["kanban"]["columnOrder"]["status"]
.as_array()
.expect("the board declares its status column order")
.iter()
.map(|v| v.as_str().expect("value"))
.collect();
ordered.sort_unstable();

assert_eq!(ordered, statuses);
}

#[test]
fn extended_priorities_map_to_real_base_values() {
const BASE: [&str; 5] = ["highest", "high", "medium", "low", "lowest"];
Expand Down
27 changes: 26 additions & 1 deletion packages/core/src/methodology/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,10 @@ pub struct ViewStep {
/// What the view selects.
pub definition: QueryDefinition,
/// The view configuration: `lastView` (`list` | `table` | `kanban`), plus
/// `kanban.groupBy` naming the field whose values become columns.
/// `kanban.groupBy` naming the field whose values become columns and,
/// where the board's columns should not follow the enum's own order,
/// `kanban.columnOrder`: that field's values in column order, keyed by
/// the field name.
pub view_config: serde_json::Value,
}

Expand Down Expand Up @@ -338,6 +341,28 @@ mod tests {
view.view_id
);
}
let column_order = &view.view_config["kanban"]["columnOrder"];
if !column_order.is_null() {
let per_field = column_order.as_object().unwrap_or_else(|| {
panic!("{}: columnOrder must be keyed by field", view.view_id)
});
let group_by = view.view_config["kanban"]["groupBy"].as_str();
assert!(
group_by.is_some_and(|field| per_field.contains_key(field)),
"{}: columnOrder must order the groupBy field",
view.view_id
);
for (field, values) in per_field {
let all_strings = values
.as_array()
.is_some_and(|vs| vs.iter().all(|v| v.is_string()));
assert!(
all_strings,
"{}: columnOrder.{field} must be a list of enum values",
view.view_id
);
}
}

// A misspelt target is a valid identifier and an empty board.
let target = definition.target_type.as_str();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,22 @@
*/

import { createLogger } from '$lib/utils/logger';
import { DEFAULT_VIEW_CONFIG, type QueryViewConfigState } from './query-node-model';
import {
DEFAULT_VIEW_CONFIG,
parseViewConfigObject,
type QueryViewConfigState
} from './query-node-model';

const log = createLogger('DefaultViewPrefs');

const STORAGE_PREFIX = 'nodespace:default-view:';

function isViewKind(value: unknown): value is QueryViewConfigState['lastView'] {
return value === 'list' || value === 'table' || value === 'kanban';
}

/** Read the remembered view for a type; the built-in default when none/invalid. */
export function loadDefaultViewPrefs(typeId: string): QueryViewConfigState {
try {
const raw = localStorage.getItem(STORAGE_PREFIX + typeId);
if (!raw) return { ...DEFAULT_VIEW_CONFIG };
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return { ...DEFAULT_VIEW_CONFIG };
const obj = parsed as Record<string, unknown>;
const result: QueryViewConfigState = {
lastView: isViewKind(obj.lastView) ? obj.lastView : DEFAULT_VIEW_CONFIG.lastView
};
const kanban = obj.kanban;
if (kanban && typeof kanban === 'object') {
const groupBy = (kanban as Record<string, unknown>).groupBy;
if (typeof groupBy === 'string') result.kanban = { groupBy };
}
return result;
return parseViewConfigObject(JSON.parse(raw));
} catch (e) {
log.debug('Default view prefs unavailable, using defaults', { typeId, error: String(e) });
return { ...DEFAULT_VIEW_CONFIG };
Expand Down
26 changes: 23 additions & 3 deletions packages/desktop-app/src/lib/components/query/kanban-grouping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,31 @@ export function eligibleGroupByFields(schema: SchemaNode | null): SchemaField[]
/**
* The ordered set of columns for an enum field: its core values followed by its
* user-extensible values, each mapped to `{ value, label }`.
*
* `order` is the query's stored column order for this field
* (`viewConfig.kanban.columnOrder[field]`). Listed values come first, in the
* listed order; values it omits follow in enum order, so a value added to the
* enum later still gets a column. A listed value the enum no longer has is
* ignored.
*/
export function enumColumns(field: SchemaField | undefined | null): KanbanColumn[] {
export function enumColumns(
field: SchemaField | undefined | null,
order?: readonly string[]
): KanbanColumn[] {
if (!field) return [];
const all = [...(field.coreValues ?? []), ...(field.userValues ?? [])];
return all.map((ev) => ({ value: ev.value, label: ev.label }));
const all = [...(field.coreValues ?? []), ...(field.userValues ?? [])].map((ev) => ({
value: ev.value,
label: ev.label
}));
if (!Array.isArray(order) || order.length === 0) return all;

const byValue = new Map(all.map((col) => [col.value, col]));
const listed = new Map<string, KanbanColumn>();
for (const value of order) {
const col = byValue.get(value);
if (col) listed.set(value, col);
}
return [...listed.values(), ...all.filter((col) => !listed.has(col.value))];
}

/**
Expand Down
Loading