├── frontend ├── .Rhistory └── index.js ├── .gitignore ├── .gitattributes ├── .DS_Store ├── block.json ├── .block ├── remote.json └── obb.remote.json ├── package.json ├── .eslintrc.js ├── LICENSE.md └── README.md /frontend/.Rhistory: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.airtableblocksrc.json 3 | /build -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thekamillionaire/check-out-in-block/HEAD/.DS_Store -------------------------------------------------------------------------------- /block.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "frontendEntry": "./frontend/index.js" 4 | } 5 | -------------------------------------------------------------------------------- /.block/remote.json: -------------------------------------------------------------------------------- 1 | { 2 | "blockId": "blk6uZbFRrrfVfqqV", 3 | "baseId": "app4f1LyZFjCB11Ha" 4 | } 5 | -------------------------------------------------------------------------------- /.block/obb.remote.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseId": "appRftdoYMV93Bt3M", 3 | "blockId": "blkwtErw1sKPhs4C4" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@airtable/blocks": "0.0.53", 4 | "react": "^16.13.0", 5 | "react-dom": "^16.13.0" 6 | }, 7 | "devDependencies": { 8 | "eslint": "^6.8.0", 9 | "eslint-plugin-react": "^7.18.3", 10 | "eslint-plugin-react-hooks": "^2.5.0" 11 | }, 12 | "scripts": { 13 | "lint": "eslint frontend" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | }, 6 | extends: ['eslint:recommended', 'plugin:react/recommended'], 7 | globals: { 8 | Atomics: 'readonly', 9 | SharedArrayBuffer: 'readonly', 10 | }, 11 | parserOptions: { 12 | ecmaFeatures: { 13 | jsx: true, 14 | }, 15 | ecmaVersion: 2018, 16 | sourceType: 'module', 17 | }, 18 | plugins: ['react', 'react-hooks'], 19 | rules: { 20 | 'react/prop-types': 0, 21 | 'react-hooks/rules-of-hooks': 'error', 22 | 'react-hooks/exhaustive-deps': 'warn', 23 | }, 24 | settings: { 25 | react: { 26 | version: 'detect', 27 | }, 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright 2020 Airtable 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 6 | associated documentation files (the "Software"), to deal in the Software without restriction, 7 | including without limitation the rights to use, copy, modify, merge, publish, distribute, 8 | sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT 12 | NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 13 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES 14 | OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 15 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Check Out/In Tracker Block 2 | This block is a customizable wizard which, in its most basic form, allows users to select a **Unit** record and create a new linked record in a **Log** table detailing when that record was "checked out" with relevant fields pre-filled to save time. The wizard just as easily allows users to check that unit back in to close out the log and make that unit available for selection once more. Advanced features include tracking a unit's condition over time (i.e. the unit was in "great" condition when it was checked out, but was in "poor" condition when it was checked back in), and the ability to enable a **Items** table for bases structured where each unit is an instance of a type of item (i.e. 5 "Macbook Pros", 6 "Samsung 32-inch TVs", etc.). 3 | 4 | This block will make day-to-day data entry for inventory managers, IT departments, librarians, equipment rental businesses, and more all that much easier! 5 | 6 | [![video preview](http://img.youtube.com/vi/AZPdeZNQwO4/0.jpg)](http://www.youtube.com/watch?v=AZPdeZNQwO4 "Video preview") 7 | 8 | ## How to remix this block 9 | 1. Create a new base (or you can use an existing base). 10 | 2. Create a new block in your base (see [Create a new block](https://airtable.com/developers/blocks/guides/hello-world-tutorial#create-a-new-block), selecting "Remix from Github" as your template. 11 | 3. From the root of your new block, run `block run`. 12 | 13 | ## Notes 14 | For the optional setting for tracking a unit's condition, make sure that all the condition single select fields have overlapping field option values. -------------------------------------------------------------------------------- /frontend/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | initializeBlock, 3 | useBase, 4 | useRecords, 5 | useGlobalConfig, 6 | useSettingsButton, 7 | useRecordById, 8 | useRecordActionData, 9 | Box, 10 | Dialog, 11 | Heading, 12 | Icon, 13 | TablePickerSynced, 14 | ViewPickerSynced, 15 | FieldPickerSynced, 16 | SwitchSynced, 17 | expandRecord, 18 | expandRecordList, 19 | expandRecordPickerAsync, 20 | RecordCard, 21 | FormField, 22 | Button, 23 | Select, 24 | Text, 25 | TextButton, 26 | Tooltip, 27 | ViewportConstraint 28 | } from '@airtable/blocks/ui'; 29 | import {settingsButton, cursor} from '@airtable/blocks'; 30 | import { FieldType, ViewType } from '@airtable/blocks/models'; 31 | import React, {useState} from 'react'; 32 | 33 | function CheckOutBlock() { 34 | const base = useBase(); 35 | const globalConfig = useGlobalConfig(); 36 | 37 | const GlobalConfigKeys = { 38 | UNITS_TABLE_ID: "unitsTableId", 39 | UNITS_VIEW_ID: "unitsViewId", 40 | UNITS_CONDITION_FIELD_ID: "unitsConditionFieldId", 41 | UNITS_ORIGIN_DATE_FIELD_ID: "unitsOriginDateFieldId", 42 | UNITS_LINK_ITEMS_FIELD_ID: "unitsLinkItemsFieldId", 43 | UNITS_LINK_LOG_FIELD_ID: "unitsLinkLogFieldId", 44 | OPT_TRACK_ITEMS: "optTrackItems", 45 | OPT_TRACK_CONDITION: "optTrackCondition", 46 | OPT_TRACK_ORIGIN_DATE: "optTrackOriginDate", 47 | LOG_VIEW_ID: "logViewId", 48 | LOG_OUT_CONDITION_FIELD_ID: "logCheckOutConditionFieldId", 49 | LOG_OUT_DATE_FIELD_ID: "logCheckOutDateFieldId", 50 | LOG_IN_CONDITION_FIELD_ID: "logCheckInConditionFieldId", 51 | LOG_IN_DATE_FIELD_ID: "logCheckInDateFieldId" 52 | } 53 | 54 | const unitsTableId = globalConfig.get(GlobalConfigKeys.UNITS_TABLE_ID) 55 | const unitsViewId = globalConfig.get(GlobalConfigKeys.UNITS_VIEW_ID) 56 | const unitsConditionFieldId = globalConfig.get(GlobalConfigKeys.UNITS_CONDITION_FIELD_ID) 57 | const unitsOriginDateFieldId = globalConfig.get(GlobalConfigKeys.UNITS_ORIGIN_DATE_FIELD_ID) 58 | const unitsLinkItemsFieldId = globalConfig.get(GlobalConfigKeys.UNITS_LINK_ITEMS_FIELD_ID) 59 | const unitsLinkLogFieldId = globalConfig.get(GlobalConfigKeys.UNITS_LINK_LOG_FIELD_ID) 60 | const optTrackItems = globalConfig.get(GlobalConfigKeys.OPT_TRACK_ITEMS) 61 | const optTrackCondition = globalConfig.get(GlobalConfigKeys.OPT_TRACK_CONDITION) 62 | const optTrackOriginDate = globalConfig.get(GlobalConfigKeys.OPT_TRACK_ORIGIN_DATE) 63 | 64 | const unitsTable = base.getTableByIdIfExists(unitsTableId) 65 | const unitsLinkLogField = unitsTable ? unitsTable.getFieldByIdIfExists(unitsLinkLogFieldId) : null 66 | const logTableId = unitsLinkLogField ? unitsLinkLogField.options.linkedTableId : null 67 | const logViewId = globalConfig.get(GlobalConfigKeys.LOG_VIEW_ID) 68 | const logCheckOutConditionFieldId = globalConfig.get(GlobalConfigKeys.LOG_OUT_CONDITION_FIELD_ID) 69 | const logCheckOutDateFieldId = globalConfig.get(GlobalConfigKeys.LOG_OUT_DATE_FIELD_ID) 70 | const logCheckInConditionFieldId = globalConfig.get(GlobalConfigKeys.LOG_IN_CONDITION_FIELD_ID) 71 | const logCheckInDateFieldId = globalConfig.get(GlobalConfigKeys.LOG_IN_DATE_FIELD_ID) 72 | 73 | // Check if all settings options have values 74 | const initialSetupDone = (unitsTableId && unitsViewId && unitsLinkLogFieldId && logTableId && logViewId && logCheckOutDateFieldId && logCheckInDateFieldId) && (!optTrackCondition || unitsConditionFieldId && logCheckOutConditionFieldId && logCheckInConditionFieldId) && (!optTrackOriginDate || unitsOriginDateFieldId) && (!optTrackItems || unitsLinkItemsFieldId) ? true : false 75 | 76 | // Enable the settings button 77 | const [isShowingSettings, setIsShowingSettings] = useState(!initialSetupDone); 78 | useSettingsButton(function() { 79 | initialSetupDone && setIsShowingSettings(!isShowingSettings); 80 | }); 81 | 82 | // Get tables, view ids, and field ids 83 | const availableUnits = useRecords(unitsTable ? unitsTable.getViewByIdIfExists(unitsViewId) : null) 84 | const unitsConditionsField = unitsTable ? unitsTable.getFieldByIdIfExists(unitsConditionFieldId) : null 85 | const unitsConditions = unitsConditionsField ? unitsConditionsField.options.choices.map(x => x.name) : null 86 | const unitsLinkItemsField = unitsTable ? unitsTable.getFieldByIdIfExists(unitsLinkItemsFieldId) : null 87 | 88 | const itemsTableId = unitsLinkItemsField ? unitsLinkItemsField.options.linkedTableId : null 89 | const itemsTable = base.getTableByIdIfExists(itemsTableId) 90 | const itemsLinkUnitsFieldId = unitsLinkItemsField ? unitsLinkItemsField.options.inverseLinkFieldId : null 91 | const itemsLinkUnitsField = itemsTable ? itemsTable.getFieldByIdIfExists(itemsLinkUnitsFieldId) : null 92 | 93 | const logTable = base.getTableByIdIfExists(logTableId) 94 | const checkedOutRecords = useRecords(logTable ? logTable.getViewByIdIfExists(logViewId) : null) 95 | const logLinkUnitsFieldId = unitsLinkLogField ? unitsLinkLogField.options.inverseLinkFieldId : null 96 | 97 | const logCheckOutConditionField = logTable && logCheckOutConditionFieldId ? logTable.getFieldByIdIfExists(logCheckOutConditionFieldId) : null 98 | const logCheckOutConditions = logCheckOutConditionField ? logCheckOutConditionField.options.choices.map(x => x.name) : null 99 | const logCheckInConditionField = logTable && logCheckInConditionFieldId ? logTable.getFieldByIdIfExists(logCheckInConditionFieldId) : null 100 | const logCheckInConditions = logCheckInConditionField ? logCheckInConditionField.options.choices.map(x => x.name) : null 101 | const logConditions = logCheckInConditions && logCheckOutConditions ? logCheckOutConditions.filter(x => logCheckInConditions.includes(x)) : null 102 | // Return the single select options found in all three conditions fields, prevents errors thrown if trying to copy an incompatable option from another field 103 | const sharedConditions = unitsConditions && logConditions ? unitsConditions.filter(x => logConditions.includes(x)) : null 104 | const bestCondition = sharedConditions ? sharedConditions[0] : null 105 | 106 | const today = new Date() 107 | const truncateText= {overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"} 108 | 109 | // Collect info from record button type press 110 | const recordActionData = useRecordActionData(); 111 | 112 | const tableId = recordActionData ? recordActionData.tableId : null 113 | const table = base.getTableByIdIfExists(tableId) 114 | const tableName = table ? table.name : null 115 | const recordId = recordActionData ? recordActionData.recordId : null 116 | const record = useRecordById(table, recordId) 117 | 118 | // The "mode" determines what action buttons are shown, and the variables used in the async functions 119 | const modes = [optTrackItems ? itemsTableId : null, unitsTableId, logTableId] 120 | const mode = modes.indexOf(tableId) 121 | 122 | // Items Actions (variables and functions for use in the action buttons) 123 | const unitsRecordsLinkedToItem = useRecords(record && mode == 0 ? record.selectLinkedRecordsFromCell(itemsLinkUnitsField) : null) 124 | // Returns records which are available (determined by presence in a view), and which have a Condition cell value that can be used in the other two condition fields 125 | const availableLinkedUnits = unitsRecordsLinkedToItem ? 126 | unitsRecordsLinkedToItem.filter(x => availableUnits.map(y => y.id).includes(x.id) && (!optTrackCondition || sharedConditions && sharedConditions.includes(x.getCellValue(unitsConditionFieldId).name))) : null 127 | const anyAvailable = availableLinkedUnits != null && availableLinkedUnits.length ? true : false 128 | const bestAvailable = availableLinkedUnits 129 | ? availableLinkedUnits.sort((a, b) => ( 130 | optTrackCondition && unitsConditionFieldId && sharedConditions.indexOf(a.getCellValue(unitsConditionFieldId).name) > sharedConditions.indexOf(b.getCellValue(unitsConditionFieldId).name) 131 | ) ? 1 : -1)[0] : null; 132 | 133 | // Units Actions (variables and functions for use in the action buttons) 134 | const logRecordsLinkedToUnit = useRecords(record && mode == 1 ? record.selectLinkedRecordsFromCell(unitsLinkLogField) : null) 135 | const checkedOutRecordsLinkedToUnit = logRecordsLinkedToUnit ? logRecordsLinkedToUnit.filter(x => checkedOutRecords.map(y => y.id).includes(x.id)) : null 136 | const recordIsAvailable = mode == 1 && availableUnits && availableUnits.map(y => y.id).includes(record.id) ? true : false 137 | const hasHistory = logRecordsLinkedToUnit != null && logRecordsLinkedToUnit.length ? true : false 138 | 139 | const checkCanCreateUnit = unitsTable ? unitsTable.checkPermissionsForCreateRecord() : null 140 | const checkCanUpdateUnit = unitsTable ? unitsTable.checkPermissionsForUpdateRecord() : null 141 | 142 | async function createNewUnit() { 143 | const fieldsAndValues = { 144 | [unitsLinkItemsFieldId]: [{id: record.id}], 145 | ...optTrackCondition && {[unitsConditionFieldId]: {name: bestCondition}}, 146 | ...optTrackOriginDate && {[unitsOriginDateFieldId]: today} 147 | } 148 | 149 | const newRecordId = await unitsTable.createRecordAsync(fieldsAndValues) 150 | const query = await unitsTable.selectRecordsAsync() 151 | expandRecord(query.getRecordById(newRecordId)) 152 | query.unloadData() 153 | } 154 | 155 | // Log Actions (variables and functions for use in the action buttons) 156 | const conditionsChoices = sharedConditions ? sharedConditions.map(x => {return ({value: x, label: x})}) : [] 157 | const recordIsCheckedIn = mode == 2 && checkedOutRecords.map(y => y.id).includes(record.id) ? false : true 158 | const unitsRecordsLinkedToLog = useRecords(record && mode == 2 ? record.selectLinkedRecordsFromCell(logLinkUnitsFieldId) : null) 159 | 160 | 161 | const checkCanCreateLog = logTable ? logTable.checkPermissionsForCreateRecord() : null 162 | 163 | async function viewHistory() { 164 | const recordA = await expandRecordList(logRecordsLinkedToUnit) 165 | } 166 | 167 | async function checkOutUnitAuto() { 168 | const logCheckOutInput = mode == 0 ? bestAvailable : record 169 | const fieldsAndValues = { 170 | [logLinkUnitsFieldId]: [{id: logCheckOutInput.id}], 171 | [logCheckOutDateFieldId]: today, 172 | ...optTrackCondition && {[logCheckOutConditionFieldId]: {name: logCheckOutInput.getCellValue(unitsConditionFieldId).name}}, 173 | } 174 | 175 | const newRecordId = await logTable.createRecordAsync(fieldsAndValues) 176 | const query = await logTable.selectRecordsAsync() 177 | expandRecord(query.getRecordById(newRecordId)) 178 | query.unloadData() 179 | } 180 | 181 | async function checkOutUnitSelect() { 182 | const logCheckOutInput = await expandRecordPickerAsync(availableLinkedUnits) 183 | 184 | if (logCheckOutInput) { 185 | const fieldsAndValues = { 186 | [logLinkUnitsFieldId]: [{id: logCheckOutInput.id}], 187 | [logCheckOutDateFieldId]: today, 188 | ...optTrackCondition && {[logCheckOutConditionFieldId]: {name: logCheckOutInput.getCellValue(unitsConditionFieldId).name}} 189 | } 190 | const newRecordId = await logTable.createRecordAsync(fieldsAndValues) 191 | const query = await logTable.selectRecordsAsync() 192 | expandRecord(query.getRecordById(newRecordId)) 193 | query.unloadData() 194 | } 195 | } 196 | 197 | const checkCanUpdateLog = logTable ? logTable.checkPermissionsForUpdateRecord() : null 198 | 199 | const [isDialogOpen, setIsDialogOpen] = useState(false); 200 | const [newCondition, setNewCondition] = useState(null); 201 | 202 | async function checkInUnit() { 203 | const logCheckInInput = mode == 2 ? record : checkedOutRecordsLinkedToUnit[0] 204 | const fieldsAndValues = { 205 | [logCheckInDateFieldId]: today, 206 | ...optTrackCondition && {[logCheckInConditionFieldId]: {name: newCondition}} 207 | } 208 | 209 | await logTable.updateRecordAsync(logCheckInInput, fieldsAndValues) 210 | 211 | const unitInput = mode == 2 ? unitsRecordsLinkedToLog[0] : record 212 | 213 | if(optTrackCondition && unitInput) { 214 | 215 | await unitsTable.updateRecordAsync(unitInput, { 216 | [unitsConditionFieldId]: {name: newCondition} 217 | }) 218 | setNewCondition(null) 219 | } 220 | setIsDialogOpen(false) 221 | } 222 | 223 | const emptyViewportText = !initialSetupDone ? "Please complete all settings" : "Click an action button associated with this block" 224 | 225 | return ( 226 | 227 | 228 | 229 | {(!initialSetupDone || !recordActionData || recordActionData && !modes.includes(tableId)) && ( 230 | 231 | {emptyViewportText} 232 | 233 | )} 234 | {initialSetupDone && recordActionData && modes.includes(tableId) && ( 235 | 236 | 237 | 238 | Table 239 | {recordActionData && (table.name)} 240 | 241 | 242 | Record 243 | {recordActionData && (record.name)} 244 | 245 | 246 | 247 | 248 | {mode == 0 && ( 249 | 250 | 257 | 264 | 271 | 272 | )} 273 | {mode == 1 && ( 274 | 275 | 282 | 289 | setIsDialogOpen(true) : checkInUnit} 294 | buttonText="Check in" 295 | /> 296 | 297 | )} 298 | {mode == 2 && ( 299 | 300 | setIsDialogOpen(true) : checkInUnit} 305 | buttonText="Check in" 306 | /> 307 | 308 | )} 309 | {isDialogOpen && ( 310 | setIsDialogOpen(false)} width="320px"> 311 | 312 | {optTrackCondition && ( 313 | 314 | 315 |