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
25 changes: 12 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ settings:
override_timezone: ''
refresh_interval: '30'
screenly_oauth_tokens_url: 'http://localhost:3000/'
stack_field: '<single-select field name for kanban stacking, e.g. Status>'
view_id: '<your Airtable view ID>'
```

Expand Down Expand Up @@ -82,16 +81,15 @@ screenly edge-app instance create

## Configuration

| Setting | Type | Required | Description |
| ------------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `access_token` | secret | No | For testing only. In production, the access token is fetched dynamically via the Screenly OAuth service. |
| `base_id` | string | Yes | Airtable base ID (e.g. `appXXXXXXXXXXXXXX`) |
| `display_errors` | string | No | Display errors on screen for debugging (`true`/`false`). Default: `false` |
| `override_locale` | string | No | Override the locale for date formatting (e.g. `en-US`, `fr`). Defaults to GPS-based detection. |
| `override_timezone` | string | No | Override the timezone for date formatting (e.g. `America/New_York`). Defaults to GPS-based detection. |
| `refresh_interval` | string | No | How often (in seconds) to refresh Airtable data. Default: `30` |
| `stack_field` | string | No | For kanban views: name of the single-select field to stack by (e.g. `Status`). Must be a single-select field. Falls back to the first single-select field if not set. |
| `view_id` | string | Yes | Airtable view ID (e.g. `viwXXXXXXXXXXXXXX`). Supports grid and kanban views. |
| Setting | Type | Required | Description |
| ------------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------- |
| `access_token` | secret | No | For testing only. In production, the access token is fetched dynamically via the Screenly OAuth service. |
| `base_id` | string | Yes | Airtable base. Selected via dropdown in the Screenly dashboard. |
| `display_errors` | string | No | Display errors on screen for debugging (`true`/`false`). Default: `false` |
| `override_locale` | string | No | Override the locale for date formatting (e.g. `en-US`, `fr`). Defaults to GPS-based detection. |
| `override_timezone` | string | No | Override the timezone for date formatting (e.g. `America/New_York`). Defaults to GPS-based detection. |
| `refresh_interval` | string | No | How often (in seconds) to refresh Airtable data. Default: `30` |
| `view_id` | string | Yes | Airtable view. Selected via dropdown in the Screenly dashboard. Supports grid and kanban views. |

## Limitations

Expand All @@ -101,9 +99,10 @@ screenly edge-app instance create

This app uses the Screenly OAuth service to obtain an Airtable access token at runtime. For local development, the `mock-server` acts as a stand-in for that service.

## Finding Your IDs
## Finding Your IDs (Local Development)

Navigate to your view in Airtable. The base ID and view ID are present in the URL:
When running locally, `base_id` and `view_id` must be set manually. Navigate to
your view in Airtable. Both IDs are present in the URL:

```
https://airtable.com/appXXXXXXXXXXXXXX/tblXXXXXXXXXXXXXX/viwXXXXXXXXXXXXXX
Expand Down
17 changes: 6 additions & 11 deletions screenly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,12 @@ settings:
help_text: How often to refresh Airtable data. Defaults to 30 seconds.
type: number
schema_version: 1
stack_field:
type: string
default_value: ''
title: Stack Field (Kanban)
optional: true
# TODO: help_text type should use a structured oauth:airtable:* type (e.g. oauth:airtable:stack_field)
# to allow the UI to render a proper field picker, similar to base_id using oauth:airtable:base.
help_text: |
For kanban views, the name of the single-select field used to stack columns (e.g. Status). Must be a single-select field. Falls back to the first single-select field if not set.
view_id:
type: string
title: View ID
title: View
optional: false
help_text: The ID of the view to display (e.g. viwXXXXXXXXXXXXXX). Supports grid and kanban views.
help_text:
properties:
help_text: The ID of the view to display. Supports grid and kanban views.
type: oauth:airtable:view
schema_version: 1
17 changes: 6 additions & 11 deletions screenly_qc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,12 @@ settings:
help_text: How often to refresh Airtable data. Defaults to 30 seconds.
type: number
schema_version: 1
stack_field:
type: string
default_value: ''
title: Stack Field (Kanban)
optional: true
# TODO: help_text type should use a structured oauth:airtable:* type (e.g. oauth:airtable:stack_field)
# to allow the UI to render a proper field picker, similar to base_id using oauth:airtable:base.
help_text: |
For kanban views, the name of the single-select field used to stack columns (e.g. Status). Must be a single-select field. Falls back to the first single-select field if not set.
view_id:
type: string
title: View ID
title: View
optional: false
help_text: The ID of the view to display (e.g. viwXXXXXXXXXXXXXX). Supports grid and kanban views.
help_text:
properties:
help_text: The ID of the view to display. Supports grid and kanban views.
type: oauth:airtable:view
schema_version: 1
10 changes: 0 additions & 10 deletions src/kanban.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,4 @@ describe('renderKanban', () => {
)
warn.mockRestore()
})

it('when stack_field names a non-existent field, should warn and do nothing', () => {
const warn = spyOn(console, 'warn').mockImplementation(() => {})
renderKanban(RECORDS, FIELDS, 'Missing')
expect(document.querySelectorAll('.kanban-column').length).toBe(0)
expect(warn).toHaveBeenCalledWith(
'renderKanban: cannot render board: field "Missing" not found or is not a singleSelect',
)
warn.mockRestore()
})
})
12 changes: 4 additions & 8 deletions src/kanban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,22 +115,18 @@ function groupByChoice(
export function renderKanban(
records: AirtableRecord[],
fields: AirtableField[],
stackFieldName = '',
): void {
const board = document.getElementById('kanban-board')
if (!board) {
return
}
board.innerHTML = ''

const stackField = stackFieldName
? fields.find((f) => f.name === stackFieldName && f.type === 'singleSelect')
: fields.find((f) => f.type === 'singleSelect')
const stackField = fields.find((f) => f.type === 'singleSelect')
if (!stackField) {
const reason = stackFieldName
? `field "${stackFieldName}" not found or is not a singleSelect`
: 'no singleSelect field found in schema'
console.warn(`renderKanban: cannot render board: ${reason}`)
console.warn(
'renderKanban: cannot render board: no singleSelect field found in schema',
)
return
}

Expand Down
7 changes: 3 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,20 @@ document.addEventListener('DOMContentLoaded', async () => {
setupTheme()

const baseId = getSettingWithDefault<string>('base_id', '')
const stackField = getSettingWithDefault<string>('stack_field', '')
const viewId = getSettingWithDefault<string>('view_id', '')
const refreshInterval = getSettingWithDefault<number>('refresh_interval', 30)
const displayErrors =
getSettingWithDefault<string>('display_errors', 'false') === 'true'
const reportError = createErrorReporter(displayErrors)

if (!baseId) {
showError('Please configure the Base ID in settings.')
showError('Please select a Base in settings.')
signalReady()
return
}

if (!viewId) {
showError('Please configure the View ID in settings.')
showError('Please select a View in settings.')
signalReady()
return
}
Expand Down Expand Up @@ -72,7 +71,7 @@ document.addEventListener('DOMContentLoaded', async () => {
reportError,
async (token) => {
const viewData = await fetchViewData(token, baseId, viewId)
renderView(viewData, stackField)
renderView(viewData)
},
)

Expand Down
8 changes: 4 additions & 4 deletions src/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,24 +51,24 @@ describe('renderView', () => {
})

it('should set the table title', () => {
renderView(GRID_VIEW_DATA, '')
renderView(GRID_VIEW_DATA)
expect(document.getElementById('table-title')?.textContent).toBe(
'Team Directory',
)
})

it('should unhide the table title', () => {
renderView(GRID_VIEW_DATA, '')
renderView(GRID_VIEW_DATA)
expect(document.getElementById('table-title')?.hidden).toBe(false)
})

it('when viewType is grid, should render table rows', () => {
renderView(GRID_VIEW_DATA, '')
renderView(GRID_VIEW_DATA)
expect(document.querySelectorAll('#table-body tr').length).toBe(1)
})

it('when viewType is kanban, should render kanban columns', () => {
renderView(KANBAN_VIEW_DATA, '')
renderView(KANBAN_VIEW_DATA)
expect(document.querySelectorAll('.kanban-column').length).toBeGreaterThan(
0,
)
Expand Down
4 changes: 2 additions & 2 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,15 @@ export async function fetchViewData(
}
}

export function renderView(viewData: ViewData, stackField: string): void {
export function renderView(viewData: ViewData): void {
const titleEl = document.getElementById('table-title')
if (titleEl) {
titleEl.textContent = viewData.tableName
titleEl.hidden = false
}

if (viewData.viewType === 'kanban') {
renderKanban(viewData.records, viewData.fields, stackField)
renderKanban(viewData.records, viewData.fields)
showView('kanban')
} else {
const { headers, rows } = recordsToRows(viewData.records, viewData.fields, {
Expand Down