diff --git a/Documentation/blueprints/foundation-components.md b/Documentation/blueprints/foundation-components.md index dee8baab..e87ca19c 100644 --- a/Documentation/blueprints/foundation-components.md +++ b/Documentation/blueprints/foundation-components.md @@ -109,6 +109,11 @@ the matrix publishes are always the whole set. ## Boards +Card selection is opt-in: set `selectionMode` to `single` (`none` by default). Each title becomes a pressed-state toggle button: click, Enter or Space selects +it, pressing it again or Escape clears it. The board announces the selected title. Bind to `selectedItem` to read the original query row, including properties +not drawn on the card, or `null` when nothing is selected. Field mappings do not reshape this output. Removing the selected card from `cards` clears selection; +moving it between columns keeps the same original row selected. The existing `value` output remains the list of card ids and columns for saving moves. + `kanbanBoard` shows a count and the over-limit warning in each column header. Name a numeric card field in `totalField` (a deal's `amount`) and the header also shows the total of that field over the cards in the column, formatted by `format` (`currency` with a `currency` code, `percent`, `compact` or a plain number) in the element's `locale`. The totals follow a card when it is moved, by dragging or by its Move-to menu, so a diff --git a/Source/JavaScript/blueprint.default/for_KanbanBoard/given/a_board_with_binding_outputs.tsx b/Source/JavaScript/blueprint.default/for_KanbanBoard/given/a_board_with_binding_outputs.tsx new file mode 100644 index 00000000..b32f3f92 --- /dev/null +++ b/Source/JavaScript/blueprint.default/for_KanbanBoard/given/a_board_with_binding_outputs.tsx @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { render } from '@testing-library/react'; +import sinon from 'sinon'; +import { KanbanBoard, foundationElement } from '../../foundation'; + +export const columns = [{ id: 'todo', label: 'To do' }, { id: 'done', label: 'Done' }]; +export const cards = [{ id: 'one', title: 'First task', column: 'todo', ownerId: 'alice' }, { id: 'two', title: 'Second task', column: 'done', ownerId: 'bob' }]; + +export function aBoardWithBindingOutputs(properties: Record = {}) { + const published: Record = {}; + const setOutput = sinon.spy((name: string, value: unknown) => { published[name] = value; }); + const bindingOutputs = { setOutput, clearOutput: () => undefined, clearAll: () => undefined }; + const onChange = sinon.spy(); + const onSelect = sinon.spy(); + const draw = (overrides: Record = {}) => ; + const rendered = render(draw()); + return { ...rendered, published, setOutput, onChange, onSelect, update: (overrides: Record) => rendered.rerender(draw(overrides)) }; +} diff --git a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_reading_the_original_card_rows.ts b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_reading_the_original_card_rows.ts new file mode 100644 index 00000000..7eea3d6c --- /dev/null +++ b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_reading_the_original_card_rows.ts @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { readCardRows, readCards } from '../foundation/boardModel'; + +describe('when reading the original card rows', () => { + it('should key the original rows by the mapped id without copying them', () => { + const row = { productId: 42, name: 'Mug', secretToTheCard: 'retained' }; + const rows = readCardRows([row], { id: 'productId', title: 'name' }); + [...rows.keys()].should.deep.equal(['42']); + rows.get('42')!.should.equal(row); + }); + + it('should keep the first row when mapped ids repeat', () => { + const first = { productId: 42, name: 'First' }; + const rows = readCardRows([first, { productId: '42', name: 'Duplicate' }], { id: 'productId', title: 'name' }); + rows.size.should.equal(1); + rows.get('42')!.should.equal(first); + }); + + it('should skip an untitled duplicate just as the drawn cards do', () => { + const row = { id: 'a', title: 'T' }; + const supplied = [{ id: 'a' }, row]; + readCards(supplied).map(card => card.title).should.deep.equal(['T']); + readCardRows(supplied).get('a')!.should.equal(row); + }); + + it('should skip a duplicate with an invalid mapped title just as the drawn cards do', () => { + const row = { productId: 42, name: 'Mug' }; + const supplied = [{ productId: 42, name: null }, row]; + const mapping = { id: 'productId', title: 'name' }; + readCards(supplied, mapping).map(card => card.title).should.deep.equal(['Mug']); + readCardRows(supplied, mapping).get('42')!.should.equal(row); + }); + + it('should ignore invalid rows and empty or missing ids', () => { + [...readCardRows([null, [], 'not a row', {}, { id: '' }, { id: 7 }, { id: 'valid', title: 'Task' }]).keys()].should.deep.equal(['valid']); + readCardRows(undefined).size.should.equal(0); + }); +}); diff --git a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card.tsx b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card.tsx new file mode 100644 index 00000000..57552e9e --- /dev/null +++ b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card.tsx @@ -0,0 +1,129 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { cleanup, fireEvent, screen } from '@testing-library/react'; +import { afterEach } from 'vitest'; +import sinon from 'sinon'; +import { BindingMode, PropertyValueType } from '@cratis/scene.model'; +import { foundationDescriptors } from '../foundation/foundationDescriptors'; +import { aBoardWithBindingOutputs, cards } from './given/a_board_with_binding_outputs'; + +describe('when selecting a card', () => { + afterEach(cleanup); + + it('should publish the original row and announce its title with an accessible pressed state', () => { + const { published, container, onSelect } = aBoardWithBindingOutputs(); + [published.selectedItem].should.deep.equal([null]); + const button = screen.getByRole('button', { name: 'Select First task' }); + fireEvent.click(button); + published.selectedItem!.should.equal(cards[0]); + button.getAttribute('aria-pressed')!.should.equal('true'); + container.querySelectorAll('.foundation-board-card[data-selected="true"]').length.should.equal(1); + screen.getByRole('status').textContent!.should.equal('First task selected'); + onSelect.callCount.should.equal(0); + }); + + it('should clear the output when the title is pressed again', () => { + const { published } = aBoardWithBindingOutputs(); + const button = screen.getByRole('button', { name: 'Select First task' }); + fireEvent.click(button); + fireEvent.click(button); + [published.selectedItem, button.getAttribute('aria-pressed')].should.deep.equal([null, 'false']); + screen.getByRole('status').textContent!.should.equal('Selection cleared'); + }); + + it('should clear the output and announce it when Escape is pressed on the title', () => { + const { published, container } = aBoardWithBindingOutputs(); + const button = screen.getByRole('button', { name: 'Select First task' }); + fireEvent.click(button); + fireEvent.keyDown(button, { key: 'Escape' }); + [published.selectedItem, button.getAttribute('aria-pressed')].should.deep.equal([null, 'false']); + container.querySelectorAll('[data-selected="true"]').length.should.equal(0); + screen.getByRole('status').textContent!.should.equal('Selection cleared'); + }); + + it('should stop propagation only when Escape clears a selection', () => { + const { container } = aBoardWithBindingOutputs(); + const reached = sinon.spy(); + document.body.addEventListener('keydown', reached); + try { + const button = screen.getByRole('button', { name: 'Select First task' }); + fireEvent.keyDown(button, { key: 'Escape' }); + reached.callCount.should.equal(1); + fireEvent.click(button); + fireEvent.keyDown(button, { key: 'Escape' }); + reached.callCount.should.equal(1); + } finally { + document.body.removeEventListener('keydown', reached); + } + container.querySelectorAll('[data-selected="true"]').length.should.equal(0); + }); + + it('should make no announcement when Escape is pressed with nothing selected', () => { + aBoardWithBindingOutputs(); + fireEvent.keyDown(screen.getByRole('button', { name: 'Select First task' }), { key: 'Escape' }); + screen.getByRole('status').textContent!.should.equal(''); + }); + + it('should leave selection alone when Escape is pressed in the move menu', () => { + const { published } = aBoardWithBindingOutputs(); + const button = screen.getByRole('button', { name: 'Select First task' }); + fireEvent.click(button); + fireEvent.keyDown(screen.getByRole('combobox', { name: 'Move First task' }), { key: 'Escape' }); + published.selectedItem!.should.equal(cards[0]); + button.getAttribute('aria-pressed')!.should.equal('true'); + screen.getByRole('status').textContent!.should.equal('First task selected'); + }); + + it('should publish the drawn row rather than an earlier untitled duplicate', () => { + const row = { id: 'a', title: 'T', column: 'todo' }; + const { published } = aBoardWithBindingOutputs({ cards: [{ id: 'a' }, row] }); + fireEvent.click(screen.getByRole('button', { name: 'Select T' })); + published.selectedItem!.should.equal(row); + }); + + it('should publish the unmapped query row for a mapped board', () => { + const row = { productId: 42, name: 'Mug', status: 'todo', sku: 'ACM-1000' }; + const { published } = aBoardWithBindingOutputs({ cards: [row], cardId: 'productId', cardTitle: 'name', cardColumn: 'status' }); + fireEvent.click(screen.getByRole('button', { name: 'Select Mug' })); + published.selectedItem!.should.equal(row); + }); + + it('should keep the selected original row after moving and still publish the move value', () => { + const { published, onChange } = aBoardWithBindingOutputs(); + fireEvent.click(screen.getByRole('button', { name: 'Select First task' })); + fireEvent.change(screen.getByRole('combobox', { name: 'Move First task' }), { target: { value: 'done' } }); + published.selectedItem!.should.equal(cards[0]); + published.value!.should.deep.equal([{ id: 'two', column: 'done' }, { id: 'one', column: 'done' }]); + screen.getByRole('button', { name: 'Select First task' }).getAttribute('aria-pressed')!.should.equal('true'); + onChange.callCount.should.equal(1); + }); + + it('should publish selection by value rather than republishing an unchanged row', () => { + const { setOutput, update, published } = aBoardWithBindingOutputs(); + fireEvent.click(screen.getByRole('button', { name: 'Select First task' })); + const count = () => setOutput.getCalls().filter(call => call.args[0] === 'selectedItem').length; + count().should.equal(2); + update({ cards: cards.map(card => ({ ...card })) }); + count().should.equal(2); + update({ cards: [{ ...cards[0], ownerId: 'charlie' }, cards[1]] }); + count().should.equal(3); + published.selectedItem!.should.deep.equal({ ...cards[0], ownerId: 'charlie' }); + }); + + it('should allow selection even when moving is disabled', () => { + const { published } = aBoardWithBindingOutputs({ editPermission: 'disabled' }); + fireEvent.click(screen.getByRole('button', { name: 'Select First task' })); + published.selectedItem!.should.equal(cards[0]); + screen.queryAllByRole('combobox').length.should.equal(0); + }); + + it('should describe the same selected item output contract as tables and default selection to none', () => { + const descriptor = foundationDescriptors.find(item => item.displayName === 'Kanban board')!; + const output = descriptor.properties!.find(property => property.path === 'selectedItem')!; + [output.valueType, output.readOnly, output.output, output.bindingMode, output.description].should.deep.equal([ + PropertyValueType.Object, true, true, BindingMode.OneWay, 'The currently selected row, or null after selection is cleared.', + ]); + descriptor.properties!.find(property => property.path === 'selectionMode')!.default!.should.equal('none'); + }); +}); diff --git a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card_with_the_keyboard_in_chromium.browser.ts b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card_with_the_keyboard_in_chromium.browser.ts new file mode 100644 index 00000000..3b1ed057 --- /dev/null +++ b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selecting_a_card_with_the_keyboard_in_chromium.browser.ts @@ -0,0 +1,43 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { FoundationPage, openFoundationPage } from '../for_foundation/given/a_foundation_page'; + +const properties = { + selectionMode: 'single', + columns: [{ id: 'todo', label: 'To do' }, { id: 'done', label: 'Done' }], + cards: [{ id: 'one', title: 'First task', column: 'todo' }, { id: 'two', title: 'Second task', column: 'done' }], +}; + +describe('when selecting a card with the keyboard in Chromium', () => { + let chromium: FoundationPage; + beforeAll(async () => { chromium = await openFoundationPage(); }); + afterAll(async () => { await chromium.browser.close(); }); + + for (const theme of ['light', 'dark'] as const) { + it(`should select with Enter and Space and clear with Escape without accessibility violations in the ${theme} theme`, async () => { + (await chromium.audit('kanbanBoard', theme, properties)).should.deep.equal([]); + const board = chromium.page.locator('.foundation-board').first(); + const first = board.getByRole('button', { name: 'Select First task' }); + const second = board.getByRole('button', { name: 'Select Second task' }); + await first.focus(); + await first.press('Enter'); + (await first.getAttribute('aria-pressed'))!.should.equal('true'); + const move = board.getByRole('combobox', { name: 'Move First task' }); + await move.focus(); + await move.press('Escape'); + (await first.getAttribute('aria-pressed'))!.should.equal('true'); + (await chromium.audit('kanbanBoard', theme, properties)).should.deep.equal([]); + await second.focus(); + await second.press('Space'); + (await second.getAttribute('aria-pressed'))!.should.equal('true'); + (await first.getAttribute('aria-pressed'))!.should.equal('false'); + (await board.locator('[data-selected="true"]').count()).should.equal(1); + await second.press('Escape'); + (await second.getAttribute('aria-pressed'))!.should.equal('false'); + (await board.locator('[data-selected="true"]').count()).should.equal(0); + (await chromium.page.locator('.foundation-visually-hidden[role="status"]').first().textContent())!.should.equal('Selection cleared'); + chromium.errors.should.deep.equal([]); + }); + } +}); diff --git a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selection_is_not_enabled.tsx b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selection_is_not_enabled.tsx new file mode 100644 index 00000000..0906d590 --- /dev/null +++ b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_selection_is_not_enabled.tsx @@ -0,0 +1,51 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { cleanup, fireEvent, screen } from '@testing-library/react'; +import { afterEach } from 'vitest'; +import sinon from 'sinon'; +import { aBoardWithBindingOutputs } from './given/a_board_with_binding_outputs'; + +describe('when selection is not enabled', () => { + afterEach(cleanup); + + for (const selectionMode of [undefined, 'none']) { + it(`should leave titles as text and publish null with selection mode ${selectionMode ?? 'omitted'}`, () => { + const { container, published } = aBoardWithBindingOutputs({ selectionMode }); + container.querySelectorAll('.foundation-board-card button').length.should.equal(0); + container.querySelectorAll('.foundation-board-card strong').length.should.equal(2); + [published.selectedItem].should.deep.equal([null]); + }); + } + + it('should clear an existing selection when selection is turned off', () => { + const { update, published, container } = aBoardWithBindingOutputs(); + fireEvent.click(screen.getByRole('button', { name: 'Select First task' })); + update({ selectionMode: 'none' }); + [published.selectedItem].should.deep.equal([null]); + container.querySelectorAll('[data-selected="true"]').length.should.equal(0); + update({ selectionMode: 'single' }); + [published.selectedItem].should.deep.equal([null]); + }); + + it('should make no announcement and not stop propagation when Escape is pressed', () => { + const { container } = aBoardWithBindingOutputs({ selectionMode: 'none' }); + const reached = sinon.spy(); + document.body.addEventListener('keydown', reached); + try { + fireEvent.keyDown(container.querySelector('strong')!, { key: 'Escape' }); + } finally { + document.body.removeEventListener('keydown', reached); + } + reached.callCount.should.equal(1); + screen.getByRole('status').textContent!.should.equal(''); + }); + + it('should keep a moved card in its column when selection is toggled', () => { + const { update } = aBoardWithBindingOutputs({ selectionMode: 'none', editPermission: 'enabled' }); + fireEvent.change(screen.getByRole('combobox', { name: 'Move First task' }), { target: { value: 'done' } }); + (screen.getByRole('combobox', { name: 'Move First task' }) as HTMLSelectElement).value.should.equal('done'); + update({ selectionMode: 'single' }); + (screen.getByRole('combobox', { name: 'Move First task' }) as HTMLSelectElement).value.should.equal('done'); + }); +}); diff --git a/Source/JavaScript/blueprint.default/for_KanbanBoard/when_the_selected_card_is_removed.tsx b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_the_selected_card_is_removed.tsx new file mode 100644 index 00000000..43e9a7a6 --- /dev/null +++ b/Source/JavaScript/blueprint.default/for_KanbanBoard/when_the_selected_card_is_removed.tsx @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { cleanup, fireEvent, screen } from '@testing-library/react'; +import { afterEach } from 'vitest'; +import { aBoardWithBindingOutputs, cards } from './given/a_board_with_binding_outputs'; + +describe('when the selected card is removed', () => { + afterEach(cleanup); + + it('should publish null and leave no card selected', () => { + const { published, container, update } = aBoardWithBindingOutputs(); + fireEvent.click(screen.getByRole('button', { name: 'Select First task' })); + update({ cards: [cards[1]] }); + [published.selectedItem].should.deep.equal([null]); + container.querySelectorAll('[data-selected="true"]').length.should.equal(0); + container.querySelectorAll('[aria-pressed="true"]').length.should.equal(0); + update({ cards }); + [published.selectedItem].should.deep.equal([null]); + container.querySelectorAll('[aria-pressed="true"]').length.should.equal(0); + }); +}); diff --git a/Source/JavaScript/blueprint.default/foundation/KanbanBoard.tsx b/Source/JavaScript/blueprint.default/foundation/KanbanBoard.tsx index 1d523be4..1309e9a8 100644 --- a/Source/JavaScript/blueprint.default/foundation/KanbanBoard.tsx +++ b/Source/JavaScript/blueprint.default/foundation/KanbanBoard.tsx @@ -1,9 +1,9 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { DragEvent, useEffect, useMemo, useState } from 'react'; +import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react'; import { RegisteredComponentProps, useValueOutput } from '@cratis/scene.react'; -import { BoardCard, BoardColumn, BoardLane, cardsOf, lanesByAssignee, moveCard, overLimit, readCardFieldList, readCards, readColumnsOfBoard, totalOf } from './boardModel'; +import { BoardCard, BoardColumn, BoardLane, cardsOf, lanesByAssignee, moveCard, overLimit, readCardFieldList, readCardRows, readCards, readColumnsOfBoard, totalOf } from './boardModel'; import { ComponentState, readComponentState } from './ComponentState'; import { formatNumber, readLocale, readNumberFormat } from './formatting'; import { FoundationIcon } from './FoundationIcon'; @@ -29,6 +29,9 @@ const optional = (key: Key, value: unknown): { [Name in Key] * * `swimlane` set to `assignee` lays the board out in lanes, one for each person (in the order they first appear, the unassigned last), each across the same * columns: a task is moved between columns within its lane, by dragging or by its Move-to menu, and each column's total and count are for the lane. + * + * `selectionMode` set to `single` makes each card's title a toggle button (mouse, Enter or Space; Escape clears). `selectedItem` publishes the original + * query row, even with mapped fields and after a move, or null when cleared or removed from the supplied cards. Selection defaults to `none`. */ export function KanbanBoard({ element, interactions, bindingOutputs }: RegisteredComponentProps) { const columns = useMemo(() => readColumnsOfBoard(element.properties.columns), [element.properties.columns]); @@ -38,6 +41,14 @@ export function KanbanBoard({ element, interactions, bindingOutputs }: Registere }), [element.properties.cardId, element.properties.cardTitle, element.properties.cardColumn, element.properties.cardAssignee, element.properties.cardFields]); const supplied = useMemo(() => readCards(element.properties.cards, mapping), [element.properties.cards, mapping]); const suppliedKey = JSON.stringify(supplied); + const rows = useMemo(() => readCardRows(element.properties.cards, mapping), [element.properties.cards, mapping]); + const selectable = element.properties.selectionMode === 'single'; + const [selectedId, setSelectedId] = useState(); + const selectedCard = selectable ? supplied.find(card => card.id === selectedId) : undefined; + const selectedItem = selectedCard ? rows.get(selectedCard.id) ?? null : null; + const selectedKey = JSON.stringify(selectedItem); + const latestSelection = useRef(selectedItem); + latestSelection.current = selectedItem; const [cards, setCards] = useState(supplied); const [collapsed, setCollapsed] = useState>(new Set()); const [announcement, setAnnouncement] = useState(''); @@ -52,7 +63,15 @@ export function KanbanBoard({ element, interactions, bindingOutputs }: Registere const swimlane = element.properties.swimlane === 'assignee'; const people = useMemo(() => supplied.flatMap(card => (card.assignee ? [card.assignee] : [])), [suppliedKey]); - useEffect(() => setCards(supplied), [suppliedKey]); + useEffect(() => { + setCards(supplied); + }, [suppliedKey]); + useEffect(() => { + setSelectedId(current => selectable && supplied.some(card => card.id === current) ? current : undefined); + }, [suppliedKey, selectable]); + useEffect(() => { + bindingOutputs?.setOutput('selectedItem', latestSelection.current); + }, [bindingOutputs, selectedKey]); useValueOutput(bindingOutputs, cards.map(card => ({ id: card.id, column: card.column }))); const move = (id: string, to: string) => { @@ -64,12 +83,26 @@ export function KanbanBoard({ element, interactions, bindingOutputs }: Registere setAnnouncement(formatText(strings.boardMoved, { title: card.title, column: target.label })); interactions?.onChange?.(); }; + const select = (card: BoardCard) => { + const next = selectedId === card.id ? undefined : card.id; + setSelectedId(next); + setAnnouncement(next === undefined ? strings.boardSelectionCleared : formatText(strings.boardSelected, { title: card.title })); + }; + const clearWithEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + if (!(event.target instanceof HTMLElement)) return; + if (event.target.closest('input, select, textarea, [contenteditable]')) return; + if (!selectable || selectedId === undefined) return; + event.stopPropagation(); + setSelectedId(undefined); + setAnnouncement(strings.boardSelectionCleared); + }; const lanes: BoardLane[] = swimlane ? lanesByAssignee(cards, people) : []; const drop = (event: DragEvent, to: string) => { event.preventDefault(); if (dragging) move(dragging, to); setDragging(undefined); }; const toggle = (id: string) => setCollapsed(current => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const board = (scope: BoardCard[], Heading: 'h3' | 'h4', laneLabel?: string) => ( -
+
{columns.map((column: BoardColumn) => { const inColumn = cardsOf(columns, scope, column.id); const closed = collapsed.has(column.id); @@ -90,8 +123,8 @@ export function KanbanBoard({ element, interactions, bindingOutputs }: Registere
    {inColumn.length === 0 &&
  • {strings.boardNoCards}
  • } {inColumn.map(card => ( -
  • setDragging(card.id)} onDragEnd={() => setDragging(undefined)}> - {card.title} +
  • setDragging(card.id)} onDragEnd={() => setDragging(undefined)}> + {selectable ? : {card.title}} {card.assignee && {card.assignee}} {card.fields.map(field => {field.label} {field.value})} {card.badges.length > 0 && {card.badges.map(badge => {badge})}} diff --git a/Source/JavaScript/blueprint.default/foundation/boardModel.ts b/Source/JavaScript/blueprint.default/foundation/boardModel.ts index b4e08a85..2798bb03 100644 --- a/Source/JavaScript/blueprint.default/foundation/boardModel.ts +++ b/Source/JavaScript/blueprint.default/foundation/boardModel.ts @@ -59,6 +59,23 @@ export interface BoardCardFields { const textOfValue = (value: unknown): string | undefined => typeof value === 'string' ? value : typeof value === 'number' && Number.isFinite(value) ? String(value) : typeof value === 'boolean' ? String(value) : undefined; +const cardIdOf = (entry: Record, mapping: BoardCardFields): string | undefined => + mapping.id ? textOfValue(entry[mapping.id]) : typeof entry.id === 'string' ? entry.id : undefined; + +const cardTitleOf = (entry: Record, mapping: BoardCardFields): string | undefined => + mapping.title ? textOfValue(entry[mapping.title]) : typeof entry.title === 'string' ? entry.title : undefined; + +/** Reads the original query rows by card id, keeping the first row for each id. */ +export function readCardRows(value: unknown, mapping: BoardCardFields = {}): Map> { + const rows = new Map>(); + for (const entry of records(value)) { + const id = cardIdOf(entry, mapping); + if (cardTitleOf(entry, mapping) === undefined) continue; + if (id !== undefined && id.length > 0 && !rows.has(id)) rows.set(id, entry); + } + return rows; +} + /** Reads the mapping of an element's `cardFields` list: entries with a `field`, repeated fields dropped. */ export function readCardFieldList(value: unknown): { field: string; label?: string }[] { const seen = new Set(); @@ -77,8 +94,8 @@ export function readCards(value: unknown, mapping: BoardCardFields = {}): BoardC const seen = new Set(); const mapped = (mapping.fields ?? []).length > 0; return records(value).flatMap(entry => { - const id = mapping.id ? textOfValue(entry[mapping.id]) : typeof entry.id === 'string' ? entry.id : undefined; - const title = mapping.title ? textOfValue(entry[mapping.title]) : typeof entry.title === 'string' ? entry.title : undefined; + const id = cardIdOf(entry, mapping); + const title = cardTitleOf(entry, mapping); if (id === undefined || id.length === 0 || seen.has(id) || title === undefined) return []; seen.add(id); const fields = mapped diff --git a/Source/JavaScript/blueprint.default/foundation/foundationDescriptors.ts b/Source/JavaScript/blueprint.default/foundation/foundationDescriptors.ts index f547d4fb..62cca9f6 100644 --- a/Source/JavaScript/blueprint.default/foundation/foundationDescriptors.ts +++ b/Source/JavaScript/blueprint.default/foundation/foundationDescriptors.ts @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { BindingSourceKind, ComponentDescriptor, PropertyDescriptor, PropertyEditorKind, PropertyValueType } from '@cratis/scene.model'; +import { BindingMode, BindingSourceKind, ComponentDescriptor, PropertyDescriptor, PropertyEditorKind, PropertyValueType } from '@cratis/scene.model'; import { componentRegistryKey, valueOutputProperty } from '@cratis/scene.react'; import { ComponentName } from '../ComponentName'; import { defaultBlueprintName } from '../packageName'; @@ -563,6 +563,12 @@ export const foundationDescriptors: ComponentDescriptor[] = [ choice('format', 'Total format', 'Appearance', { number: 'Number', currency: 'Currency', percent: 'Percent', compact: 'Compact' }, 'number'), text('currency', 'Currency', 'Appearance', 'The ISO 4217 code of a currency total.'), rule('editPermission', 'Move permission', 'Enabled lets cards move; otherwise the board is read-only.'), + choice('selectionMode', 'Selection', 'Behavior', { none: 'None', single: 'Single' }, 'none', 'Lets a host read the selected card as its original query row.'), + { + path: 'selectedItem', label: 'Selected item', group: 'Outputs', valueType: PropertyValueType.Object, readOnly: true, + output: true, bindingMode: BindingMode.OneWay, + description: 'The currently selected row, or null after selection is cleared.', + }, valueOutputProperty(PropertyValueType.Json, 'Each card\'s id and column, published as cards move.'), ...standardStateProperties, ], diff --git a/Source/JavaScript/blueprint.default/foundation/foundationStrings.ts b/Source/JavaScript/blueprint.default/foundation/foundationStrings.ts index 5469e1c4..21dd44f9 100644 --- a/Source/JavaScript/blueprint.default/foundation/foundationStrings.ts +++ b/Source/JavaScript/blueprint.default/foundation/foundationStrings.ts @@ -181,6 +181,9 @@ export const foundationStrings = { boardMoveTo: 'Move to…', boardMoveCard: 'Move {title}', boardMoved: '{title} moved to {column}', + boardSelectCard: 'Select {title}', + boardSelected: '{title} selected', + boardSelectionCleared: 'Selection cleared', boardColumn: '{column}, {count} cards', boardCollapse: 'Collapse {column}', boardExpand: 'Expand {column}', diff --git a/Source/JavaScript/blueprint.default/shell/layout.css b/Source/JavaScript/blueprint.default/shell/layout.css index eeceba37..cbc75291 100644 --- a/Source/JavaScript/blueprint.default/shell/layout.css +++ b/Source/JavaScript/blueprint.default/shell/layout.css @@ -2717,6 +2717,23 @@ tr[data-selectable='true'] { background: var(--foundation-surface); } +.foundation-board-card[data-selected='true'] { + border-color: var(--foundation-primary); + box-shadow: var(--foundation-focus); +} + +.foundation-board-card-select { + color: var(--foundation-text); + background: var(--foundation-surface); + border: 1px solid var(--foundation-border); + border-radius: var(--foundation-radius); + padding: var(--foundation-space-2); + font: inherit; + font-weight: 600; + text-align: start; + cursor: pointer; +} + .foundation-board-card[draggable='true'] { cursor: grab; }